Skip to main content

syncular_command/
lib.rs

1//! # syncular-command — one JSON command surface over the Rust client core
2//!
3//! The command router the conformance shim proved (JSON in, JSON out, bytes
4//! as `{"$bytes": hex}`) factored into a transport-agnostic module so BOTH
5//! the stdio conformance shim AND the FFI native core dispatch through the
6//! same code. That keeps a single command surface, conformance-locked via
7//! the shim: whatever the shim exercises, the FFI core inherits.
8//!
9//! The router is generic over the `Transport` seam. The shim binds it to a
10//! stdio host (transport inverted to the harness); the FFI crate binds it to
11//! a real native HTTP+WS transport. Everything host-specific — realtime
12//! notification draining, deferred requests, event queues — stays in each
13//! host; only the pure `method → result` dispatch (and its JSON parsing) is
14//! shared here.
15
16use serde_json::{json, Value};
17use ssp2::segment::{decode_rows_segment, encode_rows_segment};
18use ssp2::{
19    decode_message, encode_message, parse_control, render_message, render_rows_segment,
20    ControlMessage,
21};
22use syncular_client::previous_version::{PreviousVersionContextConfig, PreviousVersionReadSpec};
23use syncular_client::{
24    ClientDiagnosticsRequest, ClientError, ClientLimits, CommandEffects, CommitOutcomeQuery,
25    LocalDataPurgeInput, LocalDataRebootstrapInput, Mutation, QueryOwner, QueryReadFailure,
26    RealtimePolicy, ResolveCommitOutcomeInput, SubscriptionRequest, SyncClient, Transport,
27    TransportError, WindowBase, WindowCoverage, LOCAL_AUTHORITY_UNCONFIGURED_CODE,
28};
29
30// -- bytes <-> {"$bytes": hex} (the driver-protocol byte envelope) ----------
31
32pub fn bytes_to_hex(bytes: &[u8]) -> String {
33    syncular_client::values::bytes_to_hex(bytes)
34}
35
36pub fn hex_to_bytes(hex: &str) -> Result<Vec<u8>, String> {
37    syncular_client::values::hex_to_bytes(hex)
38}
39
40pub fn bytes_value(bytes: &[u8]) -> Value {
41    Value::Object(serde_json::Map::from_iter([(
42        "$bytes".to_owned(),
43        Value::from(bytes_to_hex(bytes)),
44    )]))
45}
46
47pub fn value_bytes(value: Option<&Value>) -> Result<Vec<u8>, String> {
48    let hex = value
49        .and_then(|v| v.get("$bytes"))
50        .and_then(Value::as_str)
51        .ok_or_else(|| "expected a {\"$bytes\": hex} value".to_owned())?;
52    hex_to_bytes(hex)
53}
54
55/// The structured failure the driver protocol carries in an `error`. `details`
56/// and `retryable` carry the typed mutation fields to the host; non-mutation
57/// failures leave both at their defaults.
58#[derive(Debug, Clone)]
59pub struct CommandError {
60    pub code: String,
61    pub message: String,
62    pub details: Option<Value>,
63    pub retryable: bool,
64}
65
66impl CommandError {
67    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
68        Self {
69            code: code.into(),
70            message: message.into(),
71            details: None,
72            retryable: false,
73        }
74    }
75
76    /// Consume a structured client authoring failure without re-parsing text.
77    pub fn from_client(error: ClientError) -> Self {
78        Self {
79            code: error.code,
80            message: error.message,
81            details: error.details,
82            retryable: error.retryable,
83        }
84    }
85}
86
87impl From<(String, String)> for CommandError {
88    fn from((code, message): (String, String)) -> Self {
89        Self {
90            code,
91            message,
92            details: None,
93            retryable: false,
94        }
95    }
96}
97
98/// Parsed side effects of a `create` command that the host must apply to its
99/// own transport/clock (the router stays transport-agnostic). The client is
100/// already installed into the `Option<SyncClient>` slot by `dispatch`.
101#[derive(Debug, Default, Clone)]
102pub struct CreateEffects {
103    /// §5.4 capability the harness/host announced for its endpoints — the
104    /// host sets its transport's `supports_url_fetch` accordingly.
105    pub signed_urls: bool,
106    /// True while a security preflight is pending: a preflighted client was
107    /// installed (or entered preflight, or was shut down mid-preflight) and
108    /// `activateSecurity` has yet to complete. `dispatch` maintains this so a
109    /// replacement `create` without the `securityPreflight` flag is refused
110    /// across `shutdown`, where the client slot is empty, on a host that reuses
111    /// one `CreateEffects` across creates (the Tauri plugin, the conformance
112    /// shim, the bench harness).
113    ///
114    /// A host that allocates a fresh `CreateEffects` per create — the React
115    /// Native native module rebuilds its FFI handle on every `create` — starts
116    /// each create with this flag clear. For those hosts the persisted marker
117    /// in the client core carries the gate: a file-backed replica reopens in
118    /// preflight, and the `create` path refuses a plain re-create against it.
119    /// This in-memory flag covers the same-handle case where no file persists
120    /// the marker.
121    pub security_preflight_pending: bool,
122}
123
124/// §7.5: a read failure keeps its structured code, static message, SQLite
125/// details, and retryability across the command boundary.
126fn read_command_error(failure: QueryReadFailure) -> CommandError {
127    let details = failure.details();
128    let retryable = failure.retryable();
129    CommandError {
130        code: failure.code.unwrap_or("client.failed").to_owned(),
131        message: failure.message,
132        details,
133        retryable,
134    }
135}
136
137fn client_err(message: String) -> CommandError {
138    let code = message
139        .split_once(':')
140        .map(|(candidate, _)| candidate)
141        .filter(|candidate| candidate.starts_with("client.") || candidate.starts_with("sync."))
142        .unwrap_or("client.failed");
143    let retryable = code == "client.storage_busy";
144    CommandError {
145        code: code.to_owned(),
146        message,
147        details: None,
148        retryable,
149    }
150}
151
152/// A transport failure keeps its code and structured details across the
153/// command boundary (`sync.attach_rejected` and its `details.reason`).
154fn transport_command_error(error: TransportError) -> CommandError {
155    CommandError {
156        code: error.code,
157        message: error.message,
158        details: error.details,
159        retryable: false,
160    }
161}
162
163fn need_client(client: &mut Option<SyncClient>) -> Result<&mut SyncClient, CommandError> {
164    client
165        .as_mut()
166        .ok_or_else(|| client_err("no client instance created".to_owned()))
167}
168
169/// RFC 0005 D8: parse the host's `previousVersionContext` config with the
170/// TS keys and defaults: `enabled` (default false), `maxBytes` 8 MiB,
171/// `maxRows` 20000, `maxTables` 32, `maxRowBytes` 1 MiB, `maxAgeMs` 24h.
172/// Invalid values fail the request loudly rather than silently defaulting.
173pub fn parse_previous_version_context(
174    value: Option<&Value>,
175) -> Result<Option<PreviousVersionContextConfig>, CommandError> {
176    let Some(value) = value else {
177        return Ok(None);
178    };
179    let object = value.as_object().ok_or_else(|| {
180        client_err("sync.invalid_request: previousVersionContext must be an object".to_owned())
181    })?;
182    let enabled = match object.get("enabled") {
183        None => false,
184        Some(Value::Bool(enabled)) => *enabled,
185        Some(_) => {
186            return Err(client_err(
187                "sync.invalid_request: previousVersionContext.enabled must be a boolean".to_owned(),
188            ))
189        }
190    };
191    let mut config = PreviousVersionContextConfig {
192        enabled,
193        ..PreviousVersionContextConfig::default()
194    };
195    for (key, slot) in [
196        ("maxBytes", &mut config.max_bytes),
197        ("maxRows", &mut config.max_rows),
198        ("maxTables", &mut config.max_tables),
199        ("maxRowBytes", &mut config.max_row_bytes),
200        ("maxAgeMs", &mut config.max_age_ms),
201    ] {
202        if let Some(raw) = object.get(key) {
203            *slot = raw.as_i64().ok_or_else(|| {
204                client_err(format!(
205                    "sync.invalid_request: previousVersionContext.{key} must be a positive safe integer"
206                ))
207            })?;
208        }
209    }
210    config.validate().map_err(client_err)?;
211    Ok(Some(config))
212}
213
214/// RFC 0005 D7: `previousVersionSnapshot` params — `{table, rowIds?, limit?}`.
215fn parse_previous_version_snapshot_spec(
216    params: &Value,
217) -> Result<PreviousVersionReadSpec, CommandError> {
218    let table = params.get("table").and_then(Value::as_str).ok_or_else(|| {
219        client_err("sync.invalid_request: previousVersionSnapshot missing table".to_owned())
220    })?;
221    let row_ids = match params.get("rowIds") {
222        None | Some(Value::Null) => Vec::new(),
223        Some(Value::Array(ids)) => ids
224            .iter()
225            .map(|id| match id {
226                Value::String(id) => Ok(id.clone()),
227                Value::Number(number) => Ok(number.to_string()),
228                Value::Bool(boolean) => Ok(boolean.to_string()),
229                _ => Err(client_err(
230                    "sync.invalid_request: previousVersionSnapshot rowIds must be strings"
231                        .to_owned(),
232                )),
233            })
234            .collect::<Result<Vec<_>, _>>()?,
235        Some(_) => {
236            return Err(client_err(
237                "sync.invalid_request: previousVersionSnapshot rowIds must be an array".to_owned(),
238            ))
239        }
240    };
241    let limit = match params.get("limit") {
242        None | Some(Value::Null) => None,
243        Some(value) => Some(value.as_i64().ok_or_else(|| {
244            client_err(
245                "sync.invalid_request: previousVersionSnapshot limit must be a positive number"
246                    .to_owned(),
247            )
248        })?),
249    };
250    Ok(PreviousVersionReadSpec {
251        table: table.to_owned(),
252        row_ids,
253        limit,
254    })
255}
256
257pub fn parse_limits(value: Option<&Value>) -> Result<ClientLimits, CommandError> {
258    let mut limits = ClientLimits::default();
259    let Some(object) = value.and_then(Value::as_object) else {
260        return Ok(limits);
261    };
262    limits.limit_commits = object
263        .get("limitCommits")
264        .and_then(Value::as_i64)
265        .map(|v| v as i32);
266    limits.limit_snapshot_rows = object
267        .get("limitSnapshotRows")
268        .and_then(Value::as_i64)
269        .map(|v| v as i32);
270    limits.max_snapshot_pages = object
271        .get("maxSnapshotPages")
272        .and_then(Value::as_i64)
273        .map(|v| v as i32);
274    limits.accept = object
275        .get("accept")
276        .and_then(Value::as_u64)
277        .map(|v| v as u8);
278    limits.blob_cache_max_bytes = object.get("blobCacheMaxBytes").and_then(Value::as_i64);
279    limits.outcome_retention_max_entries = object
280        .get("outcomeRetentionMaxEntries")
281        .and_then(Value::as_u64)
282        .map(|value| value as usize);
283    limits.max_push_commits_per_request = parse_push_limit(object, "maxPushCommitsPerRequest")?;
284    limits.max_push_operations_per_request =
285        parse_push_limit(object, "maxPushOperationsPerRequest")?;
286    limits.max_push_request_bytes = parse_push_limit(object, "maxPushRequestBytes")?;
287    Ok(limits)
288}
289
290/// §7.1: a configured push limit is a positive integer. Null or absent uses
291/// the default for that limit; invalid values fail with a structured error.
292fn parse_push_limit(
293    object: &serde_json::Map<String, Value>,
294    key: &str,
295) -> Result<Option<usize>, CommandError> {
296    match object.get(key) {
297        None | Some(Value::Null) => Ok(None),
298        Some(value) => value
299            .as_u64()
300            .and_then(|n| usize::try_from(n).ok())
301            .filter(|n| *n >= 1 && *n <= u32::MAX as usize)
302            .map(Some)
303            .ok_or_else(|| CommandError {
304                code: "sync.invalid_request".to_owned(),
305                message: "push limit must be an integer in 1..=4294967295".to_owned(),
306                details: Some(serde_json::json!({ "limit": key })),
307                retryable: false,
308            }),
309    }
310}
311
312/// §8.8: parse the `realtimePolicy` create param. Absent ⇒ `optional`.
313fn parse_realtime_policy(value: Option<&Value>) -> Result<RealtimePolicy, CommandError> {
314    match value {
315        None => Ok(RealtimePolicy::Optional),
316        Some(Value::String(text)) => match text.as_str() {
317            "required" => Ok(RealtimePolicy::Required),
318            "optional" => Ok(RealtimePolicy::Optional),
319            "off" => Ok(RealtimePolicy::Off),
320            other => Err(client_err(format!(
321                "sync.invalid_request: realtimePolicy must be 'required', 'optional', or 'off', got {other:?}"
322            ))),
323        },
324        Some(_) => Err(client_err(
325            "sync.invalid_request: realtimePolicy must be a string".to_owned(),
326        )),
327    }
328}
329
330/// §5.11: parse the `encryption` config into the client's portable keyring.
331/// Shape: `{ keys: { "<keyId>": {"$bytes": "<hex>"} },
332/// keyIdColumns: { "<table>": "<column>" } }`. Keys are 32 bytes.
333pub fn parse_encryption(
334    value: &Value,
335) -> Result<syncular_client::values::EncryptionConfig, String> {
336    let mut config = syncular_client::values::EncryptionConfig::default();
337    let Some(keys) = value.get("keys").and_then(Value::as_object) else {
338        return Ok(config);
339    };
340    for (key_id, key_val) in keys {
341        let bytes =
342            value_bytes(Some(key_val)).map_err(|e| format!("encryption key {key_id:?}: {e}"))?;
343        if bytes.len() != 32 {
344            return Err(format!(
345                "encryption key {key_id:?} must be 32 bytes, got {}",
346                bytes.len()
347            ));
348        }
349        config.keys.insert(key_id.clone(), bytes);
350    }
351    if let Some(columns) = value.get("keyIdColumns") {
352        let columns = columns
353            .as_object()
354            .ok_or_else(|| "encryption keyIdColumns must be an object".to_owned())?;
355        for (table, column) in columns {
356            let column = column.as_str().ok_or_else(|| {
357                format!("encryption keyIdColumns entry for {table:?} must be a string")
358            })?;
359            if column.is_empty() {
360                return Err(format!(
361                    "encryption keyIdColumns entry for {table:?} must not be empty"
362                ));
363            }
364            config
365                .key_id_columns
366                .insert(table.clone(), column.to_owned());
367        }
368    }
369    Ok(config)
370}
371
372/// Parse an `activateSecurity` (or rotation) `headers` param: an object of
373/// string values, replacing the transport's FULL header set.
374/// The router validates the shape at the shared chokepoint; each host applies
375/// the parsed set to its own transport (the router stays transport-agnostic).
376pub fn parse_headers(value: &Value) -> Result<Vec<(String, String)>, String> {
377    let object = value.as_object().ok_or_else(|| {
378        "sync.invalid_request: headers must be an object of string values".to_owned()
379    })?;
380    let mut headers = Vec::with_capacity(object.len());
381    for (name, value) in object {
382        let value = value
383            .as_str()
384            .ok_or_else(|| format!("sync.invalid_request: header {name:?} must be a string"))?;
385        headers.push((name.clone(), value.to_owned()));
386    }
387    Ok(headers)
388}
389
390pub fn parse_mutations(value: Option<&Value>) -> Result<Vec<Mutation>, String> {
391    let list = value
392        .and_then(Value::as_array)
393        .ok_or_else(|| "mutations must be a list".to_owned())?;
394    let mut out = Vec::with_capacity(list.len());
395    for entry in list {
396        let op = entry
397            .get("op")
398            .and_then(Value::as_str)
399            .ok_or_else(|| "mutation missing op".to_owned())?;
400        let table = entry
401            .get("table")
402            .and_then(Value::as_str)
403            .ok_or_else(|| "mutation missing table".to_owned())?
404            .to_owned();
405        let base_version = entry.get("baseVersion").and_then(Value::as_i64);
406        match op {
407            "upsert" | "patch" => {
408                let mut values = entry
409                    .get("values")
410                    .and_then(Value::as_object)
411                    .cloned()
412                    .ok_or_else(|| "upsert missing values".to_owned())?;
413                decode_bigint_members(&mut values)?;
414                out.push(if op == "patch" {
415                    Mutation::Patch {
416                        table,
417                        values,
418                        base_version,
419                    }
420                } else {
421                    Mutation::Upsert {
422                        table,
423                        values,
424                        base_version,
425                    }
426                });
427            }
428            "delete" => {
429                let row_id = entry
430                    .get("rowId")
431                    .and_then(Value::as_str)
432                    .ok_or_else(|| "delete missing rowId".to_owned())?
433                    .to_owned();
434                out.push(Mutation::Delete {
435                    table,
436                    row_id,
437                    base_version,
438                });
439            }
440            other => return Err(format!("unknown mutation op {other:?}")),
441        }
442    }
443    Ok(out)
444}
445
446fn decode_bigint_members(values: &mut serde_json::Map<String, Value>) -> Result<(), String> {
447    for value in values.values_mut() {
448        let Some(decimal) = value.get("$bigint").and_then(Value::as_str) else {
449            continue;
450        };
451        let integer = decimal
452            .parse::<i64>()
453            .map_err(|_| format!("bigint value {decimal:?} is outside SQLite's i64 range"))?;
454        *value = Value::from(integer);
455    }
456    Ok(())
457}
458
459fn scopes_from_params(value: Option<&Value>) -> Result<Vec<(String, Vec<String>)>, String> {
460    match value {
461        Some(v) => syncular_client::values::json_to_scope_map(v),
462        None => Ok(Vec::new()),
463    }
464}
465
466/// Parse one `{ id, table, scopes?, params? }` registration.
467fn subscription_from_params(value: &Value) -> Result<SubscriptionRequest, String> {
468    let id = value
469        .get("id")
470        .and_then(Value::as_str)
471        .ok_or_else(|| "subscribe missing id".to_owned())?
472        .to_owned();
473    let table = value
474        .get("table")
475        .and_then(Value::as_str)
476        .ok_or_else(|| "subscribe missing table".to_owned())?
477        .to_owned();
478    Ok(SubscriptionRequest {
479        id,
480        table,
481        scopes: scopes_from_params(value.get("scopes"))?,
482        params: value
483            .get("params")
484            .and_then(Value::as_str)
485            .map(str::to_owned),
486    })
487}
488
489/// §4.8: parse a window base descriptor `{ table, variable, fixedScopes?,
490/// params? }` from a command's `base` param.
491fn window_base_from_params(value: Option<&Value>) -> Result<WindowBase, String> {
492    let object = value
493        .and_then(Value::as_object)
494        .ok_or_else(|| "setWindow/windowState missing base object".to_owned())?;
495    let table = object
496        .get("table")
497        .and_then(Value::as_str)
498        .ok_or_else(|| "window base missing table".to_owned())?
499        .to_owned();
500    let variable = object
501        .get("variable")
502        .and_then(Value::as_str)
503        .ok_or_else(|| "window base missing variable".to_owned())?
504        .to_owned();
505    let fixed_scopes = scopes_from_params(object.get("fixedScopes"))?;
506    let params = object
507        .get("params")
508        .and_then(Value::as_str)
509        .map(str::to_owned);
510    Ok(WindowBase {
511        table,
512        variable,
513        fixed_scopes,
514        params,
515    })
516}
517
518/// §7.5: parse a `coverage` array of `{base, units}` entries. Absent or null is
519/// empty; a non-array, a malformed base, or non-string units are rejected
520/// explicitly instead of silently defaulting.
521fn parse_window_coverage(value: Option<&Value>) -> Result<Vec<WindowCoverage>, String> {
522    let entries = match value {
523        None | Some(Value::Null) => return Ok(Vec::new()),
524        Some(Value::Array(entries)) => entries,
525        Some(_) => return Err("coverage must be a list".to_owned()),
526    };
527    let mut coverage = Vec::with_capacity(entries.len());
528    for entry in entries {
529        let units = match entry.get("units") {
530            None | Some(Value::Null) => Vec::new(),
531            Some(Value::Array(values)) => values
532                .iter()
533                .map(|value| value.as_str().map(str::to_owned))
534                .collect::<Option<Vec<_>>>()
535                .ok_or_else(|| "coverage units must be a list of strings".to_owned())?,
536            Some(_) => return Err("coverage units must be a list of strings".to_owned()),
537        };
538        coverage.push(WindowCoverage {
539            base: window_base_from_params(entry.get("base"))?,
540            units,
541        });
542    }
543    Ok(coverage)
544}
545
546/// §7.5 owner: `{id, tables}`, both required when present. Shared by
547/// `querySnapshot` and `snapshotRead`.
548fn parse_query_owner<'a>(
549    params: &'a Value,
550    method: &str,
551) -> Result<Option<(&'a str, Vec<&'a str>)>, CommandError> {
552    match params.get("owner") {
553        None | Some(Value::Null) => Ok(None),
554        Some(value) => {
555            let id = value
556                .get("id")
557                .and_then(Value::as_str)
558                .filter(|id| !id.is_empty());
559            let tables = value
560                .get("tables")
561                .and_then(Value::as_array)
562                .and_then(|list| {
563                    list.iter()
564                        .map(Value::as_str)
565                        .collect::<Option<Vec<&str>>>()
566                });
567            match (id, tables) {
568                (Some(id), Some(tables)) => Ok(Some((id, tables))),
569                _ => Err(client_err(format!(
570                    "sync.invalid_request: {method} owner must be {{id: non-empty string, tables: string[]}}"
571                ))),
572            }
573        }
574    }
575}
576
577/// §7.5: parse a `subscriptions`/`commitIds` list. Absent or null is empty; a
578/// non-array or a non-string entry is rejected explicitly.
579fn snapshot_string_list(value: Option<&Value>) -> Result<Vec<String>, CommandError> {
580    match value {
581        None | Some(Value::Null) => Ok(Vec::new()),
582        Some(Value::Array(items)) => items
583            .iter()
584            .map(|item| item.as_str().map(str::to_owned))
585            .collect::<Option<Vec<String>>>()
586            .ok_or_else(|| {
587                client_err(
588                    "sync.invalid_request: snapshotRead list must contain strings".to_owned(),
589                )
590            }),
591        Some(_) => Err(client_err(
592            "sync.invalid_request: snapshotRead list must be a list of strings".to_owned(),
593        )),
594    }
595}
596
597/// §5.10.5: parse the common `(table, rowId, column, name)` target of a crdt
598/// command. `name` selects the shared type inside the doc (default `"text"`,
599/// matching the TS `YjsColumn.text()` default).
600#[cfg(feature = "crdt-yjs")]
601fn crdt_target(params: &Value) -> Result<(String, String, String, String), CommandError> {
602    let table = params
603        .get("table")
604        .and_then(Value::as_str)
605        .ok_or_else(|| client_err("crdt command missing table".to_owned()))?
606        .to_owned();
607    let row_id = params
608        .get("rowId")
609        .and_then(Value::as_str)
610        .ok_or_else(|| client_err("crdt command missing rowId".to_owned()))?
611        .to_owned();
612    let column = params
613        .get("column")
614        .and_then(Value::as_str)
615        .ok_or_else(|| client_err("crdt command missing column".to_owned()))?
616        .to_owned();
617    let name = params
618        .get("name")
619        .and_then(Value::as_str)
620        .unwrap_or("text")
621        .to_owned();
622    Ok((table, row_id, column, name))
623}
624
625/// Dispatch one command against the client instance over `transport`.
626///
627/// The `create` command installs a fresh `SyncClient` into `client` and
628/// returns its parsed `CreateEffects` in the `Ok` result via `effects`; every
629/// other command mutates the existing instance. Errors come back as the
630/// driver-protocol `(code, message)` pair.
631///
632/// Generic over `T: Transport` so the shim (host-inverted transport) and the
633/// FFI core (native HTTP+WS transport) share this exact router.
634pub fn dispatch<T: Transport>(
635    transport: &mut T,
636    client: &mut Option<SyncClient>,
637    effects: &mut CreateEffects,
638    method: &str,
639    params: &Value,
640) -> Result<Value, CommandError> {
641    if method == "create" {
642        // Fail-closed against a compromised webview re-issuing `create` (or
643        // `shutdown` + `create`) WITHOUT the securityPreflight flag to exit
644        // the gate: while a preflight is pending — on the live client, or
645        // carried in `effects` across a `shutdown` — a replacement create must
646        // itself request securityPreflight; the gate opens only through
647        // activateSecurity.
648        let requests_preflight = params
649            .get("securityPreflight")
650            .and_then(Value::as_bool)
651            .unwrap_or(false);
652        let preflight_engaged = client.as_ref().map_or(
653            effects.security_preflight_pending,
654            SyncClient::security_preflight,
655        );
656        if preflight_engaged && !requests_preflight {
657            return Err(CommandError::new(
658                syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE,
659                "the local replica is in security preflight; a replacement create must itself request securityPreflight, and protected data opens only after activateSecurity",
660            ));
661        }
662    } else {
663        let allowed_during_preflight = matches!(
664            method,
665            "authoritySnapshot"
666                | "securityLifecycle"
667                | "beginSecurityPreflight"
668                | "activateSecurity"
669                | "setTransportEnabled"
670                | "purgeLocalData"
671                | "previousVersionDiscard"
672                | "localRevision"
673                | "statusSnapshot"
674                | "localAuthorityState"
675                | "shutdown"
676        );
677        if client
678            .as_ref()
679            .is_some_and(|running| running.security_preflight())
680            && !allowed_during_preflight
681        {
682            return Err(CommandError::new(
683                syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE,
684                "the local replica is in security preflight; complete quarantine checks and call activateSecurity before accessing protected data",
685            ));
686        }
687    }
688    let mut result = match method {
689        "create" => {
690            let client_id = params
691                .get("clientId")
692                .and_then(Value::as_str)
693                .map(str::to_owned);
694            let schema = params
695                .get("schema")
696                .ok_or_else(|| client_err("create missing schema".to_owned()))?;
697            let limits = parse_limits(params.get("limits"))?;
698            // RFC 0005 D8: the feature config rides the top-level key the TS
699            // client uses; the Rust core carries it on `ClientLimits` so it is
700            // resolved before the opening schema reset runs.
701            let previous_version_context =
702                parse_previous_version_context(params.get("previousVersionContext"))?;
703            let mut limits = limits;
704            limits.previous_version_context = previous_version_context;
705            if let Some(reads) = params.get("authorityReads") {
706                limits.authority_reads = serde_json::from_value(reads.clone()).map_err(|_| {
707                    client_err(
708                        "client.authority_read_forbidden: invalid authority declaration".into(),
709                    )
710                })?;
711            }
712            // §12: a host-configured local authority opens with the client's
713            // schema before the client, as the browser worker opens it.
714            transport
715                .open_local_authority(schema, params.get("dbPath").and_then(Value::as_str))
716                .map_err(transport_command_error)?;
717            // §native: a `dbPath` installs a file-backed rusqlite connection so
718            // native hosts (Tauri plugin, FFI file variant) persist across
719            // restarts; absent it, the default in-memory core (the shim's mode).
720            let mut instance = match params.get("dbPath").and_then(Value::as_str) {
721                Some(path) => SyncClient::open_path_with_identity(client_id, schema, limits, path)
722                    .map_err(client_err)?,
723                None => {
724                    SyncClient::new_with_identity(client_id, schema, limits).map_err(client_err)?
725                }
726            };
727            instance
728                .set_retain_failed_commits(
729                    params
730                        .get("retainFailedCommits")
731                        .and_then(Value::as_bool)
732                        .unwrap_or(false),
733                )
734                .map_err(client_err)?;
735            // §8.8: the realtime policy rides the create params; absent ⇒
736            // `optional` (today's socket-or-HTTP behavior).
737            let realtime_policy = parse_realtime_policy(params.get("realtimePolicy"))?;
738            instance
739                .set_realtime_policy(realtime_policy)
740                .map_err(client_err)?;
741            // Harness clock pin (§5.4 expiry runs on the virtual clock).
742            if let Some(now_ms) = params.get("nowMs").and_then(Value::as_i64) {
743                instance.set_now_ms(now_ms);
744            }
745            // §5.4 capability of the host endpoint set (accept bit 3) — the
746            // host applies it to its own transport.
747            effects.signed_urls = params
748                .get("signedUrls")
749                .and_then(Value::as_bool)
750                .unwrap_or(false);
751            // §5.11: install client-side encryption keys. Shape:
752            // { encryption: { keys: { "<keyId>": {"$bytes": "<hex>"} },
753            //                 keyIdColumns: { "<table>": "<column>" } } }.
754            let security_preflight = params
755                .get("securityPreflight")
756                .and_then(Value::as_bool)
757                .unwrap_or(false);
758            // A file-backed replica reopens in preflight when its persisted
759            // quarantine marker is set (client core restores it). Refuse a plain
760            // re-create so a rebuilt host handle — the React Native native module
761            // tears its FFI handle down on every create — cannot downgrade a
762            // quarantined replica to active. A create that itself requests
763            // securityPreflight, or an activated replica whose marker cleared,
764            // proceeds.
765            if instance.security_preflight() && !security_preflight {
766                return Err(CommandError::new(
767                    syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE,
768                    "the local replica is in security preflight; a replacement create must itself request securityPreflight, and protected data opens only after activateSecurity",
769                ));
770            }
771            if security_preflight && params.get("encryption").is_some() {
772                return Err(client_err(
773                    "sync.invalid_request: securityPreflight and encryption are mutually exclusive; install keys with activateSecurity after preflight"
774                        .to_owned(),
775                ));
776            }
777            if let Some(enc) = params.get("encryption") {
778                let config = parse_encryption(enc).map_err(client_err)?;
779                instance.set_encryption(config);
780            }
781            if security_preflight {
782                instance.begin_security_preflight();
783            }
784            if let Some(enabled) = params.get("transportEnabled") {
785                let enabled = enabled.as_bool().ok_or_else(|| {
786                    client_err("sync.invalid_request: transportEnabled must be a boolean".into())
787                })?;
788                instance.set_transport_enabled(transport, enabled);
789            }
790            effects.security_preflight_pending = security_preflight;
791            *client = Some(instance);
792            Ok(json!({}))
793        }
794        "securityLifecycle" => Ok(json!({
795            "state": need_client(client)?.security_lifecycle()
796        })),
797        "beginSecurityPreflight" => {
798            let running = need_client(client)?;
799            running.disconnect_realtime(transport);
800            running.begin_security_preflight();
801            effects.security_preflight_pending = true;
802            Ok(json!({}))
803        }
804        "authoritySnapshot" => {
805            if !params.as_object().is_some_and(|p| p.is_empty()) {
806                return Err(client_err(
807                    "client.authority_read_forbidden: authoritySnapshot accepts no arguments"
808                        .into(),
809                ));
810            }
811            client
812                .as_ref()
813                .ok_or_else(|| client_err("client.closed: no authority client is open".into()))?
814                .authority_snapshot()
815                .map_err(client_err)
816        }
817        "activateSecurity" => {
818            let encryption = match params.get("encryption") {
819                Some(value) => parse_encryption(value).map_err(client_err)?,
820                None => syncular_client::values::EncryptionConfig::default(),
821            };
822            // Optional fresh transport headers ride the activation atomically,
823            // so a preflight that outlives the boot token starts its first
824            // sync round with valid credentials. Validated here at the shared
825            // chokepoint (invalid input keeps the gate closed); the host
826            // applies the parsed set to its own transport.
827            if let Some(headers) = params.get("headers") {
828                parse_headers(headers).map_err(client_err)?;
829            }
830            need_client(client)?
831                .activate_security(encryption)
832                .map_err(client_err)?;
833            effects.security_preflight_pending = false;
834            Ok(json!({}))
835        }
836        "setTransportEnabled" => {
837            let enabled = params
838                .get("enabled")
839                .and_then(Value::as_bool)
840                .ok_or_else(|| {
841                    client_err(
842                        "sync.invalid_request: setTransportEnabled.enabled must be a boolean"
843                            .into(),
844                    )
845                })?;
846            need_client(client)?.set_transport_enabled(transport, enabled);
847            Ok(json!({}))
848        }
849        "setHeaders" => {
850            let headers = params
851                .get("headers")
852                .ok_or_else(|| client_err("setHeaders missing headers".to_owned()))?;
853            parse_headers(headers).map_err(client_err)?;
854            Ok(json!({}))
855        }
856        "shutdown" => {
857            if let Some(running) = client.as_mut() {
858                // Capture the gate state BEFORE the shutdown barrier flips the
859                // client into preflight: an unactivated preflight stays
860                // pending across the shutdown, while an activated client may
861                // be recreated plainly afterwards.
862                effects.security_preflight_pending = running.security_preflight();
863                running.disconnect_realtime(transport);
864                // Teardown barrier only: an activated client that shuts down
865                // cleanly must not leave a durable quarantine mark, or its next
866                // plain create would be refused forever.
867                running.seal_security_on_teardown();
868            }
869            *client = None;
870            Ok(json!({}))
871        }
872        "subscribe" => {
873            need_client(client)?
874                .subscribe_many(vec![subscription_from_params(params).map_err(client_err)?])
875                .map_err(client_err)?;
876            Ok(json!({}))
877        }
878        "subscribeMany" => {
879            let requests = params
880                .get("subscriptions")
881                .and_then(Value::as_array)
882                .ok_or_else(|| client_err("subscribeMany missing subscriptions".to_owned()))?
883                .iter()
884                .map(subscription_from_params)
885                .collect::<Result<Vec<_>, _>>()
886                .map_err(client_err)?;
887            need_client(client)?
888                .subscribe_many(requests)
889                .map_err(client_err)?;
890            Ok(json!({}))
891        }
892        "unsubscribe" => {
893            let id = params
894                .get("id")
895                .and_then(Value::as_str)
896                .ok_or_else(|| client_err("unsubscribe missing id".to_owned()))?;
897            need_client(client)?.unsubscribe(id);
898            Ok(json!({}))
899        }
900        "setWindow" => {
901            let base = window_base_from_params(params.get("base")).map_err(client_err)?;
902            let units: Vec<String> = params
903                .get("units")
904                .and_then(Value::as_array)
905                .map(|arr| {
906                    arr.iter()
907                        .filter_map(|v| v.as_str().map(str::to_owned))
908                        .collect()
909                })
910                .unwrap_or_default();
911            let command_effects = need_client(client)?
912                .set_window(&base, &units)
913                .map_err(client_err)?;
914            Ok(json!({ "effects": command_effects }))
915        }
916        "windowState" => {
917            let base = window_base_from_params(params.get("base")).map_err(client_err)?;
918            let state = need_client(client)?.window_state(&base);
919            Ok(json!({ "units": state.units, "pending": state.pending }))
920        }
921        "timeWindowSugar" => {
922            let created_at_ms = params
923                .get("createdAtMs")
924                .and_then(Value::as_i64)
925                .ok_or_else(|| client_err("timeWindowSugar missing createdAtMs".to_owned()))?;
926            let count = params
927                .get("count")
928                .and_then(Value::as_u64)
929                .and_then(|value| usize::try_from(value).ok())
930                .ok_or_else(|| client_err("timeWindowSugar missing count".to_owned()))?;
931            let now_ms = params
932                .get("nowMs")
933                .and_then(Value::as_i64)
934                .ok_or_else(|| client_err("timeWindowSugar missing nowMs".to_owned()))?;
935            if params.get("unit").and_then(Value::as_str) != Some("month") {
936                return Err(client_err(
937                    "sync.invalid_request: timeWindowSugar requires the month unit".to_owned(),
938                ));
939            }
940            let unit = syncular_client::TimeBucketUnit::Month;
941            Ok(json!({
942                "bucket": syncular_client::creation_time_bucket(created_at_ms, unit)
943                    .map_err(client_err)?,
944                "units": syncular_client::last(count, unit, now_ms).map_err(client_err)?,
945            }))
946        }
947        "mutate" => {
948            let mutations = parse_mutations(params.get("mutations")).map_err(client_err)?;
949            let id = need_client(client)?
950                .mutate(mutations)
951                .map_err(CommandError::from_client)?;
952            Ok(json!({
953                "clientCommitId": id,
954                "effects": CommandEffects::interactive()
955            }))
956        }
957        "patch" => {
958            let table = params
959                .get("table")
960                .and_then(Value::as_str)
961                .ok_or_else(|| client_err("patch missing table".to_owned()))?;
962            let row_id = params
963                .get("rowId")
964                .and_then(Value::as_str)
965                .ok_or_else(|| client_err("patch missing rowId".to_owned()))?;
966            let mut partial = params
967                .get("partial")
968                .and_then(Value::as_object)
969                .cloned()
970                .ok_or_else(|| client_err("patch missing partial object".to_owned()))?;
971            decode_bigint_members(&mut partial).map_err(client_err)?;
972            let base_version = params.get("baseVersion").and_then(Value::as_i64);
973            let id = need_client(client)?
974                .patch(table, row_id, partial, base_version)
975                .map_err(CommandError::from_client)?;
976            Ok(json!({
977                "clientCommitId": id,
978                "effects": CommandEffects::interactive()
979            }))
980        }
981        "purgeLocalData" => {
982            let input: LocalDataPurgeInput =
983                serde_json::from_value(params.get("input").cloned().ok_or_else(|| {
984                    client_err("sync.invalid_request: purgeLocalData missing input".to_owned())
985                })?)
986                .map_err(|error| {
987                    client_err(format!(
988                        "sync.invalid_request: invalid purgeLocalData input: {error}"
989                    ))
990                })?;
991            let result = need_client(client)?
992                .purge_local_data(&input)
993                .map_err(client_err)?;
994            serde_json::to_value(result).map_err(|error| client_err(error.to_string()))
995        }
996        "rebootstrapLocalData" => {
997            let input: LocalDataRebootstrapInput =
998                serde_json::from_value(params.get("input").cloned().ok_or_else(|| {
999                    client_err(
1000                        "sync.invalid_request: rebootstrapLocalData missing input".to_owned(),
1001                    )
1002                })?)
1003                .map_err(|error| {
1004                    client_err(format!(
1005                        "sync.invalid_request: invalid rebootstrapLocalData input: {error}"
1006                    ))
1007                })?;
1008            let result = need_client(client)?
1009                .rebootstrap_local_data(&input)
1010                .map_err(client_err)?;
1011            Ok(json!({
1012                "alreadyApplied": result.already_applied,
1013                "retainedCommits": result.retained_commits,
1014                "resetSubscriptions": result.reset_subscriptions,
1015                "effects": if result.already_applied {
1016                    CommandEffects::none()
1017                } else {
1018                    CommandEffects::interactive()
1019                }
1020            }))
1021        }
1022        "sync" => {
1023            let outcome = need_client(client)?.sync(transport);
1024            Ok(outcome.to_json())
1025        }
1026        "resetClientId" | "reset_client_id" => {
1027            let client_id = need_client(client)?.reset_client_id(transport);
1028            Ok(json!({ "clientId": client_id }))
1029        }
1030        "syncUntilIdle" => {
1031            let max_rounds = match params.get("maxRounds") {
1032                None | Some(Value::Null) => None,
1033                Some(value) => Some(
1034                    value
1035                        .as_u64()
1036                        .and_then(|n| u32::try_from(n).ok())
1037                        .filter(|n| *n >= 1)
1038                        .ok_or_else(|| {
1039                            client_err(
1040                                "sync.invalid_request: maxRounds must be a positive integer"
1041                                    .to_owned(),
1042                            )
1043                        })?,
1044                ),
1045            };
1046            let timeout_ms = match params.get("timeoutMs") {
1047                None | Some(Value::Null) => None,
1048                Some(value) => Some(value.as_u64().filter(|ms| *ms >= 1).ok_or_else(|| {
1049                    client_err(
1050                        "sync.invalid_request: timeoutMs must be a positive integer".to_owned(),
1051                    )
1052                })?),
1053            };
1054            let client = need_client(client)?;
1055            let outcome = match timeout_ms {
1056                Some(timeout_ms) => {
1057                    client.sync_until_idle_with_deadline(transport, max_rounds, Some(timeout_ms))
1058                }
1059                None => client.sync_until_idle(transport, max_rounds),
1060            };
1061            Ok(outcome.to_json())
1062        }
1063        "readRows" => {
1064            let table = params
1065                .get("table")
1066                .and_then(Value::as_str)
1067                .ok_or_else(|| client_err("readRows missing table".to_owned()))?;
1068            let rows = need_client(client)?.read_rows(table).map_err(client_err)?;
1069            Ok(json!({ "rows": rows }))
1070        }
1071        "query" => {
1072            // The React `useSyncQuery` live-query fast path: arbitrary read-only
1073            // SQL over the local visible tables/views. Params ride as the driver
1074            // value forms (bytes as `{"$bytes": hex}`); rows come back the same.
1075            let sql = params
1076                .get("sql")
1077                .and_then(Value::as_str)
1078                .ok_or_else(|| client_err("query missing sql".to_owned()))?;
1079            let bind = match params.get("params") {
1080                Some(Value::Array(list)) => list.as_slice(),
1081                None | Some(Value::Null) => &[],
1082                Some(_) => return Err(client_err("query params must be a list".to_owned())),
1083            };
1084            let rows = need_client(client)?.query(sql, bind).map_err(client_err)?;
1085            Ok(Value::Object(serde_json::Map::from_iter([(
1086                "rows".to_owned(),
1087                Value::Array(rows.into_iter().map(Value::Object).collect()),
1088            )])))
1089        }
1090        "querySnapshot" => {
1091            let sql = params
1092                .get("sql")
1093                .and_then(Value::as_str)
1094                .ok_or_else(|| client_err("querySnapshot missing sql".to_owned()))?;
1095            let bind = match params.get("params") {
1096                Some(Value::Array(list)) => list.as_slice(),
1097                None | Some(Value::Null) => &[],
1098                Some(_) => {
1099                    return Err(client_err("querySnapshot params must be a list".to_owned()))
1100                }
1101            };
1102            let coverage = parse_window_coverage(params.get("coverage")).map_err(client_err)?;
1103            let owner = parse_query_owner(params, "querySnapshot")?;
1104            let mut snapshot = need_client(client)?
1105                .query_snapshot(
1106                    sql,
1107                    bind,
1108                    &coverage,
1109                    owner.as_ref().map(|(id, tables)| QueryOwner { id, tables }),
1110                )
1111                .map_err(client_err)?;
1112            let rows = std::mem::take(&mut snapshot.rows);
1113            let mut result = serde_json::to_value(snapshot).expect("snapshot serializes");
1114            result["rows"] = Value::Array(rows.into_iter().map(Value::Object).collect());
1115            Ok(result)
1116        }
1117        "localRevision" => {
1118            // §7.5: the public revision read is fallible. An empty shared
1119            // snapshot read reports a corrupt marker instead of swallowing it
1120            // to zero. The internal infallible counter stays for bookkeeping.
1121            let read = need_client(client)?
1122                .snapshot_read(&syncular_client::SnapshotReadRequest::default(), None)
1123                .map_err(read_command_error)?;
1124            Ok(json!({ "revision": read.revision }))
1125        }
1126        "snapshotRead" => {
1127            let mut statement_sql: Vec<String> = Vec::new();
1128            let mut statement_params: Vec<Vec<Value>> = Vec::new();
1129            match params.get("statements") {
1130                None | Some(Value::Null) => {}
1131                Some(Value::Array(items)) => {
1132                    for item in items {
1133                        let Some(sql) = item.get("sql").and_then(Value::as_str) else {
1134                            return Err(client_err(
1135                                "sync.invalid_request: snapshotRead statements need a sql string"
1136                                    .to_owned(),
1137                            ));
1138                        };
1139                        let bind = match item.get("params") {
1140                            None | Some(Value::Null) => Vec::new(),
1141                            Some(Value::Array(values)) => values.clone(),
1142                            Some(_) => {
1143                                return Err(client_err(
1144                                    "sync.invalid_request: snapshotRead statement params must be a list"
1145                                        .to_owned(),
1146                                ))
1147                            }
1148                        };
1149                        statement_sql.push(sql.to_owned());
1150                        statement_params.push(bind);
1151                    }
1152                }
1153                Some(_) => {
1154                    return Err(client_err(
1155                        "sync.invalid_request: snapshotRead statements must be a list".to_owned(),
1156                    ))
1157                }
1158            }
1159            let subscription_ids = snapshot_string_list(params.get("subscriptions"))?;
1160            let commit_ids = snapshot_string_list(params.get("commitIds"))?;
1161            let coverage = parse_window_coverage(params.get("coverage")).map_err(client_err)?;
1162            let owner = parse_query_owner(params, "snapshotRead")?;
1163            let statements: Vec<syncular_client::SnapshotStatement<'_>> = statement_sql
1164                .iter()
1165                .zip(&statement_params)
1166                .map(|(sql, params)| syncular_client::SnapshotStatement { sql, params })
1167                .collect();
1168            let read = need_client(client)?
1169                .snapshot_read(
1170                    &syncular_client::SnapshotReadRequest {
1171                        statements,
1172                        coverage: &coverage,
1173                        subscriptions: subscription_ids,
1174                        commit_ids,
1175                    },
1176                    owner.as_ref().map(|(id, tables)| QueryOwner { id, tables }),
1177                )
1178                .map_err(read_command_error)?;
1179            serde_json::to_value(read).map_err(|error| client_err(error.to_string()))
1180        }
1181        "previousVersionSnapshot" | "previous_version_snapshot" => {
1182            let spec = parse_previous_version_snapshot_spec(params)?;
1183            let snapshot = need_client(client)?
1184                .previous_version_snapshot(&spec)
1185                .map_err(client_err)?;
1186            serde_json::to_value(snapshot).map_err(|error| client_err(error.to_string()))
1187        }
1188        "previousVersionAudit" | "previous_version_audit" => {
1189            Ok(match need_client(client)?.previous_version_audit() {
1190                Some(audit) => {
1191                    serde_json::to_value(audit).map_err(|error| client_err(error.to_string()))?
1192                }
1193                None => Value::Null,
1194            })
1195        }
1196        "previousVersionDiscard" | "previous_version_discard" => {
1197            let outcome = need_client(client)?.previous_version_discard();
1198            Ok(json!({
1199                "present": outcome.present,
1200                "discarded": outcome.discarded,
1201            }))
1202        }
1203        "statusSnapshot" => Ok(serde_json::to_value(need_client(client)?.status_snapshot())
1204            .expect("status serializes")),
1205        "diagnosticsSnapshot" => {
1206            let request = serde_json::from_value::<ClientDiagnosticsRequest>(params.clone())
1207                .map_err(|error| {
1208                    client_err(format!(
1209                        "sync.invalid_request: invalid diagnostics request: {error}"
1210                    ))
1211                })?;
1212            let snapshot = need_client(client)?
1213                .diagnostics_snapshot(&request)
1214                .map_err(client_err)?;
1215            Ok(serde_json::to_value(snapshot).expect("diagnostics serialize"))
1216        }
1217        // Conformance/debug drains. Production hosts normally drain these
1218        // immediately after every command, but exposing the exact core output
1219        // here lets both client implementations consume one observation
1220        // vector catalog without bridge inference.
1221        "progressSnapshot" => Ok(
1222            serde_json::to_value(need_client(client)?.progress().snapshot())
1223                .expect("progress JSON"),
1224        ),
1225        "drainChangeBatches" => Ok(json!({
1226            "batches": need_client(client)?.drain_change_batches()
1227        })),
1228        "drainSyncIntents" => Ok(json!({
1229            "intents": need_client(client)?.drain_sync_intents()
1230        })),
1231        // -- §5.10.5 native CRDT (the `crdt-yjs` feature) -----------------------
1232        // Thin forwards to the client core's yrs helpers. The command surface
1233        // stays present-but-unavailable in a lean build: without the feature
1234        // these fail loudly (`client.crdt_unavailable`) rather than being an
1235        // unknown method, so a wrapper's typed method gives a clear error.
1236        #[cfg(feature = "crdt-yjs")]
1237        "crdtText" => {
1238            let (table, row_id, column, name) = crdt_target(params)?;
1239            let text = need_client(client)?
1240                .crdt_text(&table, &row_id, &column, &name)
1241                .map_err(client_err)?;
1242            Ok(json!({ "text": text }))
1243        }
1244        #[cfg(feature = "crdt-yjs")]
1245        "crdtInsertText" => {
1246            let (table, row_id, column, name) = crdt_target(params)?;
1247            let index = params
1248                .get("index")
1249                .and_then(Value::as_u64)
1250                .ok_or_else(|| client_err("crdtInsertText missing index".to_owned()))?
1251                as u32;
1252            let value = params
1253                .get("value")
1254                .and_then(Value::as_str)
1255                .ok_or_else(|| client_err("crdtInsertText missing value".to_owned()))?;
1256            let id = need_client(client)?
1257                .crdt_insert_text(&table, &row_id, &column, &name, index, value)
1258                .map_err(CommandError::from_client)?;
1259            Ok(json!({ "clientCommitId": id }))
1260        }
1261        #[cfg(feature = "crdt-yjs")]
1262        "crdtDeleteText" => {
1263            let (table, row_id, column, name) = crdt_target(params)?;
1264            let index = params
1265                .get("index")
1266                .and_then(Value::as_u64)
1267                .ok_or_else(|| client_err("crdtDeleteText missing index".to_owned()))?
1268                as u32;
1269            let len = params
1270                .get("len")
1271                .and_then(Value::as_u64)
1272                .ok_or_else(|| client_err("crdtDeleteText missing len".to_owned()))?
1273                as u32;
1274            let id = need_client(client)?
1275                .crdt_delete_text(&table, &row_id, &column, &name, index, len)
1276                .map_err(CommandError::from_client)?;
1277            Ok(json!({ "clientCommitId": id }))
1278        }
1279        #[cfg(feature = "crdt-yjs")]
1280        "crdtApplyUpdate" => {
1281            let (table, row_id, column, _name) = crdt_target(params)?;
1282            let update = value_bytes(params.get("update")).map_err(client_err)?;
1283            let id = need_client(client)?
1284                .crdt_apply_update(&table, &row_id, &column, &update)
1285                .map_err(CommandError::from_client)?;
1286            Ok(json!({ "clientCommitId": id }))
1287        }
1288        #[cfg(not(feature = "crdt-yjs"))]
1289        "crdtText" | "crdtInsertText" | "crdtDeleteText" | "crdtApplyUpdate" => {
1290            Err(CommandError::new(
1291                "client.crdt_unavailable",
1292                "native CRDT support requires the `crdt-yjs` feature (§5.10.5)",
1293            ))
1294        }
1295
1296        "uploadBlob" => {
1297            let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
1298            let media_type = params
1299                .get("mediaType")
1300                .and_then(Value::as_str)
1301                .map(str::to_owned);
1302            let name = params
1303                .get("name")
1304                .and_then(Value::as_str)
1305                .map(str::to_owned);
1306            let reference = need_client(client)?
1307                .upload_blob(&bytes, media_type, name)
1308                .map_err(client_err)?;
1309            Ok(json!({ "ref": reference }))
1310        }
1311        "fetchBlob" => {
1312            let blob = params
1313                .get("blob")
1314                .and_then(Value::as_str)
1315                .ok_or_else(|| client_err("fetchBlob missing blob".to_owned()))?
1316                .to_owned();
1317            // fetch_blob_bytes returns (code, message) so the server's blob.*
1318            // code reaches the caller (§5.9.5 cross-scope probe). The command
1319            // boundary owns the JSON byte encoding used by native bindings.
1320            let blob = need_client(client)?.fetch_blob_bytes(transport, &blob)?;
1321            let mut value = serde_json::Map::new();
1322            value.insert("blobId".to_owned(), Value::from(blob.blob_id));
1323            value.insert("byteLength".to_owned(), Value::from(blob.byte_length));
1324            value.insert("bytes".to_owned(), bytes_value(&blob.bytes));
1325            if let Some(media_type) = blob.media_type {
1326                value.insert("mediaType".to_owned(), Value::from(media_type));
1327            }
1328            Ok(Value::Object(serde_json::Map::from_iter([(
1329                "blob".to_owned(),
1330                Value::Object(value),
1331            )])))
1332        }
1333        "conflicts" => {
1334            let conflicts = need_client(client)?.conflicts().to_vec();
1335            Ok(json!({ "conflicts": conflicts }))
1336        }
1337        "rejections" => {
1338            let rejections = need_client(client)?.rejections().to_vec();
1339            Ok(json!({ "rejections": rejections }))
1340        }
1341        "commitOutcome" => {
1342            let client_commit_id = params
1343                .get("clientCommitId")
1344                .and_then(Value::as_str)
1345                .ok_or_else(|| {
1346                    client_err(
1347                        "sync.invalid_request: commitOutcome missing clientCommitId".to_owned(),
1348                    )
1349                })?;
1350            let outcome = need_client(client)?
1351                .commit_outcome(client_commit_id)
1352                .map_err(client_err)?;
1353            Ok(json!({ "outcome": outcome }))
1354        }
1355        "commitOutcomes" => {
1356            let query = serde_json::from_value::<CommitOutcomeQuery>(
1357                params.get("query").cloned().unwrap_or_else(|| json!({})),
1358            )
1359            .map_err(|error| {
1360                client_err(format!(
1361                    "sync.invalid_request: invalid commit outcome query: {error}"
1362                ))
1363            })?;
1364            let outcomes = need_client(client)?
1365                .commit_outcomes(query)
1366                .map_err(client_err)?;
1367            Ok(json!({ "outcomes": outcomes }))
1368        }
1369        "resolveCommitOutcome" => {
1370            let input = serde_json::from_value::<ResolveCommitOutcomeInput>(
1371                params.get("input").cloned().ok_or_else(|| {
1372                    client_err(
1373                        "sync.invalid_request: resolveCommitOutcome missing input".to_owned(),
1374                    )
1375                })?,
1376            )
1377            .map_err(|error| {
1378                client_err(format!(
1379                    "sync.invalid_request: invalid outcome resolution: {error}"
1380                ))
1381            })?;
1382            let outcome = need_client(client)?
1383                .resolve_commit_outcome(input)
1384                .map_err(client_err)?;
1385            Ok(json!({ "outcome": outcome }))
1386        }
1387        "pendingCommitIds" => {
1388            let ids = need_client(client)?.pending_commit_ids();
1389            Ok(json!({ "ids": ids }))
1390        }
1391        "pendingPayloads" => {
1392            let payloads = need_client(client)?.pending_payloads();
1393            Ok(json!({
1394                "payloads": payloads.iter().map(|p| bytes_value(p)).collect::<Vec<_>>()
1395            }))
1396        }
1397        "subscriptionState" => {
1398            let id = params
1399                .get("id")
1400                .and_then(Value::as_str)
1401                .ok_or_else(|| client_err("subscriptionState missing id".to_owned()))?;
1402            let state = need_client(client)?.subscription_state(id);
1403            Ok(json!({ "state": state }))
1404        }
1405        "recreateWithSchema" => {
1406            // §7.4.2 "app ships new code": swap to the new schema on the SAME
1407            // in-memory database (the Rust core has no persistent restart, so
1408            // recreation IS the boot). Fires the §7.4.1 marker check.
1409            let schema = params
1410                .get("schema")
1411                .ok_or_else(|| client_err("recreateWithSchema missing schema".to_owned()))?;
1412            need_client(client)?
1413                .recreate_with_schema(schema)
1414                .map_err(client_err)?;
1415            Ok(json!({}))
1416        }
1417        "localAuthorityState" => Ok(json!({ "state": transport.local_authority_state() })),
1418        "attachLocalAuthority" | "abandonLocalAuthorityAttach" => {
1419            let running = need_client(client)?;
1420            if transport.local_authority_state().is_none() {
1421                return Err(CommandError::new(
1422                    LOCAL_AUTHORITY_UNCONFIGURED_CODE,
1423                    "the host configured no localAuthorityPath",
1424                ));
1425            }
1426            // §12.3: hold the client's rounds while the authority uploads or
1427            // rolls back, then restore the host's transport gate.
1428            let enabled = running.transport_enabled();
1429            running.set_transport_enabled(transport, false);
1430            let outcome = if method == "attachLocalAuthority" {
1431                transport.attach_local_authority()
1432            } else {
1433                transport.abandon_local_authority_attach()
1434            };
1435            running.set_transport_enabled(transport, enabled);
1436            outcome.map_err(transport_command_error)?;
1437            Ok(json!({ "effects": CommandEffects::interactive() }))
1438        }
1439        "connectRealtime" => {
1440            need_client(client)?
1441                .connect_realtime(transport)
1442                .map_err(client_err)?;
1443            Ok(json!({}))
1444        }
1445        "disconnectRealtime" => {
1446            need_client(client)?.disconnect_realtime(transport);
1447            Ok(json!({}))
1448        }
1449        "setPresence" => {
1450            let scope_key = params
1451                .get("scopeKey")
1452                .and_then(Value::as_str)
1453                .ok_or_else(|| client_err("setPresence missing scopeKey".to_owned()))?
1454                .to_owned();
1455            // §8.6.2: `doc` may be a JSON object or null (a leave).
1456            let doc = params.get("doc");
1457            let doc_ref = match doc {
1458                None | Some(Value::Null) => None,
1459                Some(v) => Some(v),
1460            };
1461            need_client(client)?
1462                .set_presence(transport, &scope_key, doc_ref)
1463                .map_err(client_err)?;
1464            Ok(json!({}))
1465        }
1466        "presence" => {
1467            let scope_key = params
1468                .get("scopeKey")
1469                .and_then(Value::as_str)
1470                .ok_or_else(|| client_err("presence missing scopeKey".to_owned()))?;
1471            let peers = need_client(client)?.presence(scope_key);
1472            Ok(json!({ "peers": peers }))
1473        }
1474
1475        // -- CodecDriver surface (Appendix A) — no client instance needed --
1476        "messageRoundtrip" => {
1477            let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
1478            match decode_message(&bytes) {
1479                Ok(message) => Ok(json!({
1480                    "ok": true,
1481                    "bytes": bytes_value(&encode_message(&message)),
1482                    "renderedJson": render_message(&message).to_string(),
1483                })),
1484                Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
1485            }
1486        }
1487        "segmentRoundtrip" => {
1488            let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
1489            match decode_rows_segment(&bytes) {
1490                Ok(segment) => Ok(json!({
1491                    "ok": true,
1492                    "bytes": bytes_value(&encode_rows_segment(&segment)),
1493                    "renderedJson": render_rows_segment(&segment).to_string(),
1494                })),
1495                Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
1496            }
1497        }
1498        "realtimeKnown" => {
1499            let text = params
1500                .get("text")
1501                .and_then(Value::as_str)
1502                .ok_or_else(|| client_err("realtimeKnown missing text".to_owned()))?;
1503            let known = matches!(
1504                parse_control(text),
1505                Ok(ControlMessage::Hello { .. })
1506                    | Ok(ControlMessage::Wake { .. })
1507                    | Ok(ControlMessage::Heartbeat { .. })
1508                    | Ok(ControlMessage::Presence { .. })
1509            );
1510            Ok(json!({ "value": known }))
1511        }
1512
1513        other => Err(client_err(format!("unknown method {other:?}"))),
1514    };
1515    if client
1516        .as_ref()
1517        .is_some_and(|running| !running.transport_enabled())
1518    {
1519        if let Ok(value) = &mut result {
1520            if let Some(effects) = value.get_mut("effects").and_then(Value::as_object_mut) {
1521                effects.remove("sync");
1522            }
1523        }
1524    }
1525    result
1526}
1527
1528#[cfg(test)]
1529mod tests {
1530    use serde_json::{json, Value};
1531    use syncular_client::{
1532        Mutation, SegmentRequest, SyncClient, Transport, TransportError,
1533        SECURITY_PREFLIGHT_REQUIRED_CODE,
1534    };
1535
1536    use super::{
1537        client_err, dispatch, parse_encryption, parse_headers, parse_previous_version_context,
1538        CreateEffects,
1539    };
1540
1541    #[derive(Default)]
1542    struct NoNetwork {
1543        realtime_connects: usize,
1544        realtime_closes: usize,
1545    }
1546
1547    impl Transport for NoNetwork {
1548        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1549            Err(TransportError::new("sync.transport_failed", "offline"))
1550        }
1551
1552        fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1553            Err(TransportError::new("sync.transport_failed", "offline"))
1554        }
1555
1556        fn download_segment(
1557            &mut self,
1558            _request: &SegmentRequest,
1559            _on_progress: &mut dyn FnMut(u64),
1560        ) -> Result<Vec<u8>, TransportError> {
1561            Err(TransportError::new("sync.transport_failed", "offline"))
1562        }
1563
1564        fn realtime_connect(&mut self) -> Result<(), TransportError> {
1565            self.realtime_connects += 1;
1566            Ok(())
1567        }
1568
1569        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1570            Ok(())
1571        }
1572
1573        fn realtime_close(&mut self) -> Result<(), TransportError> {
1574            self.realtime_closes += 1;
1575            Ok(())
1576        }
1577    }
1578
1579    fn schema() -> Value {
1580        json!({
1581            "version": 1,
1582            "tables": [{
1583                "name": "todos",
1584                "columns": [
1585                    { "name": "id", "type": "string", "nullable": false },
1586                    { "name": "list_id", "type": "string", "nullable": false }
1587                ],
1588                "primaryKey": "id",
1589                "scopes": [{ "pattern": "list:{list_id}", "column": "list_id" }]
1590            }]
1591        })
1592    }
1593
1594    #[test]
1595    fn native_command_realtime_connection_is_idempotent() {
1596        let mut transport = NoNetwork::default();
1597        let mut client: Option<SyncClient> = None;
1598        let mut effects = CreateEffects::default();
1599        dispatch(
1600            &mut transport,
1601            &mut client,
1602            &mut effects,
1603            "create",
1604            &json!({ "schema": schema() }),
1605        )
1606        .expect("create client");
1607
1608        for _ in 0..2 {
1609            dispatch(
1610                &mut transport,
1611                &mut client,
1612                &mut effects,
1613                "connectRealtime",
1614                &json!({}),
1615            )
1616            .expect("idempotent connect command");
1617        }
1618        assert_eq!(transport.realtime_connects, 1);
1619
1620        dispatch(
1621            &mut transport,
1622            &mut client,
1623            &mut effects,
1624            "disconnectRealtime",
1625            &json!({}),
1626        )
1627        .expect("disconnect command");
1628        dispatch(
1629            &mut transport,
1630            &mut client,
1631            &mut effects,
1632            "disconnectRealtime",
1633            &json!({}),
1634        )
1635        .expect("idempotent disconnect command");
1636        assert_eq!(transport.realtime_closes, 1);
1637        dispatch(
1638            &mut transport,
1639            &mut client,
1640            &mut effects,
1641            "connectRealtime",
1642            &json!({}),
1643        )
1644        .expect("deliberate reconnect command");
1645        assert_eq!(transport.realtime_connects, 2);
1646    }
1647
1648    #[test]
1649    fn local_authority_commands_without_one_report_the_unconfigured_code() {
1650        let mut transport = NoNetwork::default();
1651        let mut client: Option<SyncClient> = None;
1652        let mut effects = CreateEffects::default();
1653        let state = dispatch(
1654            &mut transport,
1655            &mut client,
1656            &mut effects,
1657            "localAuthorityState",
1658            &json!({}),
1659        )
1660        .expect("state without a client");
1661        assert_eq!(state, json!({ "state": null }));
1662        dispatch(
1663            &mut transport,
1664            &mut client,
1665            &mut effects,
1666            "create",
1667            &json!({ "schema": schema() }),
1668        )
1669        .expect("create client");
1670        for method in ["attachLocalAuthority", "abandonLocalAuthorityAttach"] {
1671            let error = dispatch(
1672                &mut transport,
1673                &mut client,
1674                &mut effects,
1675                method,
1676                &json!({}),
1677            )
1678            .expect_err("no local authority");
1679            assert_eq!(error.code, "client.local_authority_unconfigured");
1680        }
1681        let running = client.as_mut().expect("client");
1682        assert!(running.transport_enabled());
1683        assert!(
1684            running.drain_sync_intents().is_empty(),
1685            "the gate never toggled"
1686        );
1687    }
1688
1689    /// A host transport with a scripted §12 local authority.
1690    struct ScriptedAuthority {
1691        opened: Vec<(Value, Option<String>)>,
1692        open_error: Option<TransportError>,
1693        attach_error: Option<TransportError>,
1694        abandons: usize,
1695    }
1696
1697    impl Transport for ScriptedAuthority {
1698        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1699            Err(TransportError::new("sync.transport_failed", "offline"))
1700        }
1701        fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1702            Err(TransportError::new("sync.transport_failed", "offline"))
1703        }
1704        fn download_segment(
1705            &mut self,
1706            _request: &SegmentRequest,
1707            _on_progress: &mut dyn FnMut(u64),
1708        ) -> Result<Vec<u8>, TransportError> {
1709            Err(TransportError::new("sync.transport_failed", "offline"))
1710        }
1711        fn realtime_connect(&mut self) -> Result<(), TransportError> {
1712            Ok(())
1713        }
1714        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1715            Ok(())
1716        }
1717        fn realtime_close(&mut self) -> Result<(), TransportError> {
1718            Ok(())
1719        }
1720        fn open_local_authority(
1721            &mut self,
1722            schema: &Value,
1723            replica_path: Option<&str>,
1724        ) -> Result<(), TransportError> {
1725            self.opened
1726                .push((schema.clone(), replica_path.map(str::to_owned)));
1727            self.open_error.take().map_or(Ok(()), Err)
1728        }
1729        fn local_authority_state(&self) -> Option<&'static str> {
1730            (!self.opened.is_empty()).then_some("local")
1731        }
1732        fn attach_local_authority(&mut self) -> Result<(), TransportError> {
1733            self.attach_error.take().map_or(Ok(()), Err)
1734        }
1735        fn abandon_local_authority_attach(&mut self) -> Result<(), TransportError> {
1736            self.abandons += 1;
1737            Ok(())
1738        }
1739    }
1740
1741    #[test]
1742    fn local_authority_opens_at_create_and_attach_errors_keep_code_and_details() {
1743        let mut transport = ScriptedAuthority {
1744            opened: Vec::new(),
1745            open_error: Some(TransportError::new("sync.internal_error", "open failed")),
1746            attach_error: Some(TransportError {
1747                code: "sync.attach_rejected".to_owned(),
1748                message: "the remote server refused an attach commit".to_owned(),
1749                details: Some(json!({ "reason": "commit_rejected", "opIndex": 0 })),
1750            }),
1751            abandons: 0,
1752        };
1753        let mut client: Option<SyncClient> = None;
1754        let mut effects = CreateEffects::default();
1755        let error = dispatch(
1756            &mut transport,
1757            &mut client,
1758            &mut effects,
1759            "create",
1760            &json!({ "schema": schema() }),
1761        )
1762        .expect_err("an authority that fails to open fails create");
1763        assert_eq!(error.code, "sync.internal_error");
1764        assert!(client.is_none());
1765
1766        dispatch(
1767            &mut transport,
1768            &mut client,
1769            &mut effects,
1770            "create",
1771            &json!({ "schema": schema() }),
1772        )
1773        .expect("create client");
1774        assert_eq!(transport.opened.len(), 2);
1775        assert_eq!(transport.opened[1], (schema(), None));
1776        let state = dispatch(
1777            &mut transport,
1778            &mut client,
1779            &mut effects,
1780            "localAuthorityState",
1781            &json!({}),
1782        )
1783        .expect("state");
1784        assert_eq!(state, json!({ "state": "local" }));
1785
1786        let error = dispatch(
1787            &mut transport,
1788            &mut client,
1789            &mut effects,
1790            "attachLocalAuthority",
1791            &json!({}),
1792        )
1793        .expect_err("scripted rejection");
1794        assert_eq!(error.code, "sync.attach_rejected");
1795        assert_eq!(
1796            error.details,
1797            Some(json!({ "reason": "commit_rejected", "opIndex": 0 }))
1798        );
1799        assert!(client.as_ref().expect("client").transport_enabled());
1800
1801        let abandoned = dispatch(
1802            &mut transport,
1803            &mut client,
1804            &mut effects,
1805            "abandonLocalAuthorityAttach",
1806            &json!({}),
1807        )
1808        .expect("abandon");
1809        assert_eq!(abandoned["effects"]["sync"]["kind"], "interactive");
1810        assert_eq!(transport.abandons, 1);
1811
1812        // An offline host stays offline across attach.
1813        dispatch(
1814            &mut transport,
1815            &mut client,
1816            &mut effects,
1817            "setTransportEnabled",
1818            &json!({ "enabled": false }),
1819        )
1820        .expect("go offline");
1821        let attached = dispatch(
1822            &mut transport,
1823            &mut client,
1824            &mut effects,
1825            "attachLocalAuthority",
1826            &json!({}),
1827        )
1828        .expect("attach");
1829        assert!(attached["effects"].get("sync").is_none());
1830        assert!(!client.as_ref().expect("client").transport_enabled());
1831    }
1832
1833    #[test]
1834    fn schema_downgrade_has_structured_code_at_create_and_recreate_boundaries() {
1835        let path = temp_db_path("schema-downgrade");
1836        let mut newer = schema();
1837        newer["version"] = json!(3);
1838        let mut older = schema();
1839        older["version"] = json!(2);
1840        let mut transport = NoNetwork::default();
1841        let mut client = None;
1842        let mut effects = CreateEffects::default();
1843        dispatch(
1844            &mut transport,
1845            &mut client,
1846            &mut effects,
1847            "create",
1848            &json!({ "schema": newer, "dbPath": path }),
1849        )
1850        .expect("v3 create");
1851        let error = dispatch(
1852            &mut transport,
1853            &mut client,
1854            &mut effects,
1855            "recreateWithSchema",
1856            &json!({ "schema": older }),
1857        )
1858        .expect_err("refuse recreation");
1859        assert_eq!(error.code, "client.schema_downgrade");
1860        assert_eq!(
1861            error.message,
1862            "client.schema_downgrade: persisted local schema is newer than the requested schema"
1863        );
1864        dispatch(
1865            &mut transport,
1866            &mut client,
1867            &mut effects,
1868            "shutdown",
1869            &json!({}),
1870        )
1871        .expect("shutdown");
1872        let error = dispatch(
1873            &mut transport,
1874            &mut client,
1875            &mut effects,
1876            "create",
1877            &json!({ "schema": older, "dbPath": path }),
1878        )
1879        .expect_err("refuse open");
1880        assert_eq!(error.code, "client.schema_downgrade");
1881        assert!(client.is_none());
1882        dispatch(
1883            &mut transport,
1884            &mut client,
1885            &mut effects,
1886            "create",
1887            &json!({ "schema": newer, "dbPath": path }),
1888        )
1889        .expect("compatible reopen");
1890        dispatch(
1891            &mut transport,
1892            &mut client,
1893            &mut effects,
1894            "shutdown",
1895            &json!({}),
1896        )
1897        .expect("shutdown");
1898        let _ = std::fs::remove_file(path);
1899    }
1900
1901    #[test]
1902    fn native_command_realtime_policy_rides_create_and_refuses_http() {
1903        let mut transport = NoNetwork::default();
1904        let mut client: Option<SyncClient> = None;
1905        let mut effects = CreateEffects::default();
1906        dispatch(
1907            &mut transport,
1908            &mut client,
1909            &mut effects,
1910            "create",
1911            &json!({ "schema": schema(), "realtimePolicy": "required" }),
1912        )
1913        .expect("create client with a required realtime policy");
1914        let result = dispatch(
1915            &mut transport,
1916            &mut client,
1917            &mut effects,
1918            "sync",
1919            &json!({}),
1920        )
1921        .expect("sync command");
1922        assert_eq!(result["ok"], json!(false));
1923        assert_eq!(result["errorCode"], json!("sync.realtime_unavailable"));
1924        assert_eq!(result["realtimeState"], json!("disconnected"));
1925        assert_eq!(result["retryDelayMs"], json!(250));
1926        let snapshot = dispatch(
1927            &mut transport,
1928            &mut client,
1929            &mut effects,
1930            "diagnosticsSnapshot",
1931            &json!({}),
1932        )
1933        .expect("diagnostics command");
1934        assert_eq!(snapshot["host"]["realtime"], json!("disconnected"));
1935        assert_eq!(snapshot["host"]["realtimePolicy"], json!("required"));
1936        assert_eq!(snapshot["host"]["realtimeRetryDelayMs"], json!(250));
1937
1938        let mut second: Option<SyncClient> = None;
1939        let error = dispatch(
1940            &mut transport,
1941            &mut second,
1942            &mut effects,
1943            "create",
1944            &json!({ "schema": schema(), "realtimePolicy": "always" }),
1945        )
1946        .expect_err("an unknown policy is refused");
1947        assert_eq!(error.code, "sync.invalid_request");
1948    }
1949
1950    #[test]
1951    fn parses_portable_encryption_keyring_and_key_id_columns() {
1952        let key_hex = "2a".repeat(32);
1953        let config = parse_encryption(&json!({
1954            "keys": { "practice-key-v1": { "$bytes": key_hex } },
1955            "keyIdColumns": { "patients": "encryption_key_id" }
1956        }))
1957        .expect("portable keyring parses");
1958        assert_eq!(config.keys["practice-key-v1"], vec![0x2a; 32]);
1959        assert_eq!(config.key_id_columns["patients"], "encryption_key_id");
1960    }
1961
1962    #[test]
1963    fn rejects_non_string_key_id_columns() {
1964        let error = parse_encryption(&json!({
1965            "keys": {},
1966            "keyIdColumns": { "patients": 7 }
1967        }))
1968        .expect_err("invalid selector must fail");
1969        assert!(error.contains("must be a string"), "{error}");
1970    }
1971
1972    #[test]
1973    fn subscribe_many_registers_every_subscription() {
1974        let mut transport = NoNetwork::default();
1975        let mut client: Option<SyncClient> = None;
1976        let mut effects = CreateEffects::default();
1977        dispatch(
1978            &mut transport,
1979            &mut client,
1980            &mut effects,
1981            "create",
1982            &json!({ "schema": schema() }),
1983        )
1984        .expect("create client");
1985        dispatch(
1986            &mut transport,
1987            &mut client,
1988            &mut effects,
1989            "subscribeMany",
1990            &json!({ "subscriptions": [
1991                { "id": "a", "table": "todos", "scopes": { "list_id": ["list-1"] } },
1992                { "id": "b", "table": "todos", "scopes": { "list_id": ["list-2"] } }
1993            ] }),
1994        )
1995        .expect("register subscriptions");
1996        for id in ["a", "b"] {
1997            let state = dispatch(
1998                &mut transport,
1999                &mut client,
2000                &mut effects,
2001                "subscriptionState",
2002                &json!({ "id": id }),
2003            )
2004            .expect("subscription state");
2005            assert_eq!(state["state"]["table"], "todos");
2006        }
2007    }
2008
2009    #[test]
2010    fn native_command_hosts_reject_subscription_identity_rebinds() {
2011        let mut transport = NoNetwork::default();
2012        let mut client: Option<SyncClient> = None;
2013        let mut effects = CreateEffects::default();
2014        dispatch(
2015            &mut transport,
2016            &mut client,
2017            &mut effects,
2018            "create",
2019            &json!({ "schema": schema() }),
2020        )
2021        .expect("create client");
2022
2023        let original = json!({
2024            "id": "stable-subscription",
2025            "table": "todos",
2026            "scopes": { "list_id": ["list-2", "list-1"] },
2027            "params": "{\"view\":\"v1\"}"
2028        });
2029        dispatch(
2030            &mut transport,
2031            &mut client,
2032            &mut effects,
2033            "subscribe",
2034            &original,
2035        )
2036        .expect("register subscription");
2037        dispatch(
2038            &mut transport,
2039            &mut client,
2040            &mut effects,
2041            "subscribe",
2042            &json!({
2043                "id": "stable-subscription",
2044                "table": "todos",
2045                "scopes": { "list_id": ["list-1", "list-2", "list-1"] },
2046                "params": "{\"view\":\"v1\"}"
2047            }),
2048        )
2049        .expect("canonical re-declaration is idempotent");
2050
2051        let error = dispatch(
2052            &mut transport,
2053            &mut client,
2054            &mut effects,
2055            "subscribe",
2056            &json!({
2057                "id": "stable-subscription",
2058                "table": "todos",
2059                "scopes": { "list_id": ["list-2"] },
2060                "params": "{\"view\":\"v1\"}"
2061            }),
2062        )
2063        .expect_err("changed native query identity must fail");
2064        assert_eq!(error.code, "client.subscription_intent_mismatch");
2065
2066        let state = dispatch(
2067            &mut transport,
2068            &mut client,
2069            &mut effects,
2070            "subscriptionState",
2071            &json!({ "id": "stable-subscription" }),
2072        )
2073        .expect("subscription state");
2074        assert_eq!(state["state"]["cursor"], -1);
2075        assert_eq!(state["state"]["table"], "todos");
2076    }
2077
2078    #[test]
2079    fn security_preflight_is_fail_closed_until_exact_activation() {
2080        let mut transport = NoNetwork::default();
2081        let mut client: Option<SyncClient> = None;
2082        let mut effects = CreateEffects::default();
2083        dispatch(
2084            &mut transport,
2085            &mut client,
2086            &mut effects,
2087            "create",
2088            &json!({ "schema": schema(), "securityPreflight": true }),
2089        )
2090        .expect("preflight create");
2091
2092        let lifecycle = dispatch(
2093            &mut transport,
2094            &mut client,
2095            &mut effects,
2096            "securityLifecycle",
2097            &json!({}),
2098        )
2099        .expect("lifecycle");
2100        assert_eq!(lifecycle, json!({ "state": "preflight" }));
2101
2102        let query_error = dispatch(
2103            &mut transport,
2104            &mut client,
2105            &mut effects,
2106            "query",
2107            &json!({ "sql": "SELECT id FROM todos", "params": [] }),
2108        )
2109        .expect_err("protected query must fail");
2110        assert_eq!(query_error.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
2111        let diagnostics_error = dispatch(
2112            &mut transport,
2113            &mut client,
2114            &mut effects,
2115            "diagnosticsSnapshot",
2116            &json!({}),
2117        )
2118        .expect_err("diagnostics table/subscription evidence remains protected");
2119        assert_eq!(diagnostics_error.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
2120
2121        dispatch(
2122            &mut transport,
2123            &mut client,
2124            &mut effects,
2125            "purgeLocalData",
2126            &json!({
2127                "input": {
2128                    "purgeId": "directive-1",
2129                    "targets": [{
2130                        "table": "todos",
2131                        "selectors": { "list_id": ["list-1"] }
2132                    }]
2133                }
2134            }),
2135        )
2136        .expect("authorized local purge remains available");
2137
2138        let repair_error = dispatch(
2139            &mut transport,
2140            &mut client,
2141            &mut effects,
2142            "rebootstrapLocalData",
2143            &json!({ "input": { "rebootstrapId": "blocked-repair" } }),
2144        )
2145        .expect_err("projection repair must remain protected during preflight");
2146        assert_eq!(repair_error.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
2147
2148        dispatch(
2149            &mut transport,
2150            &mut client,
2151            &mut effects,
2152            "activateSecurity",
2153            &json!({}),
2154        )
2155        .expect("activation");
2156        let rows = dispatch(
2157            &mut transport,
2158            &mut client,
2159            &mut effects,
2160            "query",
2161            &json!({ "sql": "SELECT id FROM todos", "params": [] }),
2162        )
2163        .expect("active query");
2164        assert_eq!(rows, json!({ "rows": [] }));
2165
2166        dispatch(
2167            &mut transport,
2168            &mut client,
2169            &mut effects,
2170            "beginSecurityPreflight",
2171            &json!({}),
2172        )
2173        .expect("re-enter preflight");
2174        let blocked = dispatch(
2175            &mut transport,
2176            &mut client,
2177            &mut effects,
2178            "mutate",
2179            &json!({ "mutations": [] }),
2180        )
2181        .expect_err("mutation must be gated");
2182        assert_eq!(blocked.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
2183    }
2184
2185    #[test]
2186    fn mutate_and_patch_forward_typed_storage_failures() {
2187        let path = temp_db_path("busy-authoring");
2188        let schema = json!({
2189            "version": 1,
2190            "tables": [{
2191                "name": "todos",
2192                "columns": [
2193                    { "name": "id", "type": "string", "nullable": false },
2194                    { "name": "list_id", "type": "string", "nullable": false },
2195                    { "name": "title", "type": "string", "nullable": false }
2196                ],
2197                "primaryKey": "id",
2198                "scopes": [{ "pattern": "list:{list_id}", "column": "list_id" }]
2199            }]
2200        });
2201        let mut transport = NoNetwork::default();
2202        let mut client: Option<SyncClient> = None;
2203        let mut effects = CreateEffects::default();
2204        dispatch(
2205            &mut transport,
2206            &mut client,
2207            &mut effects,
2208            "create",
2209            &json!({ "schema": schema, "dbPath": path }),
2210        )
2211        .expect("create file-backed client");
2212        let upsert = |id: &str| {
2213            json!({ "mutations": [{ "op": "upsert", "table": "todos", "values": {
2214                "id": id, "list_id": "l1", "title": "first" } }] })
2215        };
2216        dispatch(
2217            &mut transport,
2218            &mut client,
2219            &mut effects,
2220            "mutate",
2221            &upsert("t1"),
2222        )
2223        .expect("seed row");
2224
2225        let locker = rusqlite::Connection::open(&path).expect("second connection");
2226        locker.execute_batch("BEGIN IMMEDIATE").unwrap();
2227        let busy = dispatch(
2228            &mut transport,
2229            &mut client,
2230            &mut effects,
2231            "mutate",
2232            &upsert("t2"),
2233        )
2234        .expect_err("busy mutate");
2235        assert_eq!(busy.code, "client.storage_busy");
2236        assert!(busy.retryable);
2237        assert_eq!(busy.details.as_ref().unwrap()["sqliteCode"], 5);
2238
2239        let busy_patch = dispatch(
2240            &mut transport,
2241            &mut client,
2242            &mut effects,
2243            "patch",
2244            &json!({ "table": "todos", "rowId": "t1", "partial": { "title": "second" } }),
2245        )
2246        .expect_err("busy patch");
2247        assert_eq!(busy_patch.code, "client.storage_busy");
2248        assert!(busy_patch.retryable);
2249        assert_eq!(busy_patch.details.as_ref().unwrap()["sqliteCode"], 5);
2250
2251        locker.execute_batch("ROLLBACK").unwrap();
2252        let retried = dispatch(
2253            &mut transport,
2254            &mut client,
2255            &mut effects,
2256            "mutate",
2257            &upsert("t2"),
2258        )
2259        .expect("retry after busy");
2260        assert!(retried.get("clientCommitId").is_some());
2261        let _ = std::fs::remove_file(&path);
2262    }
2263
2264    #[test]
2265    fn read_command_error_preserves_code_details_and_retryability() {
2266        let corrupt = super::read_command_error(syncular_client::QueryReadFailure {
2267            code: Some("sync.local_corrupt"),
2268            sqlite_code: None,
2269            sqlite_message: None,
2270            rollback_failure: None,
2271            message: "persisted local revision is invalid".to_owned(),
2272        });
2273        assert_eq!(corrupt.code, "sync.local_corrupt");
2274        assert!(!corrupt.retryable);
2275        assert!(corrupt.details.is_none());
2276
2277        let busy = super::read_command_error(syncular_client::QueryReadFailure {
2278            code: Some("client.storage_busy"),
2279            sqlite_code: Some(5),
2280            sqlite_message: Some("database is locked".to_owned()),
2281            rollback_failure: None,
2282            message: "local SQLite storage is busy".to_owned(),
2283        });
2284        assert_eq!(busy.code, "client.storage_busy");
2285        assert!(busy.retryable);
2286        assert_eq!(
2287            busy.details.expect("busy details")["sqliteCode"],
2288            serde_json::json!(5)
2289        );
2290    }
2291
2292    #[test]
2293    fn mutate_and_patch_report_caller_value_failures_as_invalid_request() {
2294        let path = temp_db_path("caller-values");
2295        let schema = json!({
2296            "version": 1,
2297            "tables": [{
2298                "name": "todos",
2299                "columns": [
2300                    { "name": "id", "type": "string", "nullable": false },
2301                    { "name": "list_id", "type": "string", "nullable": false },
2302                    { "name": "title", "type": "string", "nullable": false },
2303                    { "name": "priority", "type": "integer", "nullable": false }
2304                ],
2305                "primaryKey": "id",
2306                "scopes": [{ "pattern": "list:{list_id}", "column": "list_id" }]
2307            }]
2308        });
2309        let mut transport = NoNetwork::default();
2310        let mut client: Option<SyncClient> = None;
2311        let mut effects = CreateEffects::default();
2312        dispatch(
2313            &mut transport,
2314            &mut client,
2315            &mut effects,
2316            "create",
2317            &json!({ "schema": schema, "dbPath": path }),
2318        )
2319        .expect("create file-backed client");
2320        let seed = dispatch(
2321            &mut transport,
2322            &mut client,
2323            &mut effects,
2324            "mutate",
2325            &json!({ "mutations": [{ "op": "upsert", "table": "todos", "values": {
2326                "id": "t1", "list_id": "l1", "title": "first", "priority": 1 } }] }),
2327        )
2328        .expect("seed row");
2329        let seed_id = seed["clientCommitId"].clone();
2330
2331        // §6.1/§7.1: a caller value the authoring seam rejects is an invalid
2332        // request, not the internal-failure default.
2333        let unknown = dispatch(
2334            &mut transport,
2335            &mut client,
2336            &mut effects,
2337            "mutate",
2338            &json!({ "mutations": [{ "op": "upsert", "table": "todos", "values": {
2339                "id": "t2", "list_id": "l1", "title": "second", "priority": 1,
2340                "missing_column": 1 } }] }),
2341        )
2342        .expect_err("unknown column");
2343        assert_eq!(unknown.code, "sync.invalid_request");
2344        assert_eq!(unknown.message, "the authoring request is invalid");
2345        assert!(!unknown.retryable);
2346        assert!(unknown.details.as_ref().unwrap()["legacyCause"]
2347            .as_str()
2348            .unwrap()
2349            .contains("unknown column"));
2350
2351        let bad_patch = dispatch(
2352            &mut transport,
2353            &mut client,
2354            &mut effects,
2355            "patch",
2356            &json!({ "table": "todos", "rowId": "t1", "partial": { "priority": "high" } }),
2357        )
2358        .expect_err("bad patch value");
2359        assert_eq!(bad_patch.code, "sync.invalid_request");
2360        assert_eq!(bad_patch.message, "the authoring request is invalid");
2361        assert!(bad_patch.details.as_ref().unwrap()["legacyCause"]
2362            .as_str()
2363            .unwrap()
2364            .contains("priority"));
2365
2366        // Neither rejection enqueued a commit or touched the projection.
2367        let pending = dispatch(
2368            &mut transport,
2369            &mut client,
2370            &mut effects,
2371            "pendingCommitIds",
2372            &json!({}),
2373        )
2374        .expect("pending commit ids");
2375        assert_eq!(pending["ids"], json!([seed_id]));
2376        let rows = dispatch(
2377            &mut transport,
2378            &mut client,
2379            &mut effects,
2380            "query",
2381            &json!({ "sql": "SELECT id, priority FROM todos ORDER BY id" }),
2382        )
2383        .expect("visible rows");
2384        assert_eq!(rows["rows"], json!([{ "id": "t1", "priority": 1 }]));
2385
2386        // The next valid call authorizes normally.
2387        let accepted = dispatch(
2388            &mut transport,
2389            &mut client,
2390            &mut effects,
2391            "mutate",
2392            &json!({ "mutations": [{ "op": "upsert", "table": "todos", "values": {
2393                "id": "t2", "list_id": "l1", "title": "second", "priority": 2 } }] }),
2394        )
2395        .expect("retry after rejection");
2396        assert!(accepted.get("clientCommitId").is_some());
2397        let _ = std::fs::remove_file(&path);
2398    }
2399
2400    #[test]
2401    fn client_err_preserves_retryable_for_storage_busy() {
2402        let busy = client_err("client.storage_busy: local SQLite storage is busy".to_owned());
2403        assert_eq!(busy.code, "client.storage_busy");
2404        assert!(busy.retryable);
2405        // The legacy boundary keeps the full rendered message.
2406        assert_eq!(
2407            busy.message,
2408            "client.storage_busy: local SQLite storage is busy"
2409        );
2410        let full = client_err("client.storage_full: local SQLite storage is full".to_owned());
2411        assert_eq!(full.code, "client.storage_full");
2412        assert!(!full.retryable);
2413    }
2414
2415    #[test]
2416    fn sync_until_idle_rejects_an_invalid_max_rounds_budget() {
2417        for value in [json!(0), json!(-1), json!(1.5), json!(4_294_967_296u64)] {
2418            let mut transport = NoNetwork::default();
2419            let mut client: Option<SyncClient> = None;
2420            let mut effects = CreateEffects::default();
2421            dispatch(
2422                &mut transport,
2423                &mut client,
2424                &mut effects,
2425                "create",
2426                &json!({ "schema": schema() }),
2427            )
2428            .expect("create");
2429            let error = dispatch(
2430                &mut transport,
2431                &mut client,
2432                &mut effects,
2433                "syncUntilIdle",
2434                &json!({ "maxRounds": value }),
2435            )
2436            .expect_err("invalid budget");
2437            assert_eq!(error.code, "sync.invalid_request", "{value}");
2438        }
2439    }
2440
2441    #[test]
2442    fn sync_until_idle_rejects_an_invalid_timeout() {
2443        for value in [json!(0), json!(-1), json!(1.5)] {
2444            let mut transport = NoNetwork::default();
2445            let mut client: Option<SyncClient> = None;
2446            let mut effects = CreateEffects::default();
2447            dispatch(
2448                &mut transport,
2449                &mut client,
2450                &mut effects,
2451                "create",
2452                &json!({ "schema": schema() }),
2453            )
2454            .expect("create");
2455            let error = dispatch(
2456                &mut transport,
2457                &mut client,
2458                &mut effects,
2459                "syncUntilIdle",
2460                &json!({ "timeoutMs": value }),
2461            )
2462            .expect_err("invalid timeout");
2463            assert_eq!(error.code, "sync.invalid_request", "{value}");
2464        }
2465    }
2466
2467    #[test]
2468    fn create_rejects_invalid_push_limits() {
2469        for value in [json!(0), json!(-1), json!(1.5), json!(4_294_967_296u64)] {
2470            let mut transport = NoNetwork::default();
2471            let mut client: Option<SyncClient> = None;
2472            let mut effects = CreateEffects::default();
2473            let error = dispatch(
2474                &mut transport,
2475                &mut client,
2476                &mut effects,
2477                "create",
2478                &json!({
2479                    "schema": schema(),
2480                    "limits": { "maxPushRequestBytes": value }
2481                }),
2482            )
2483            .expect_err("invalid push limit");
2484            assert_eq!(error.code, "sync.invalid_request", "{value}");
2485            assert_eq!(
2486                error.details.as_ref().unwrap()["limit"],
2487                json!("maxPushRequestBytes")
2488            );
2489        }
2490    }
2491
2492    #[test]
2493    fn preflight_allows_only_the_authorized_previous_version_discard() {
2494        let mut transport = NoNetwork::default();
2495        let mut client: Option<SyncClient> = None;
2496        let mut effects = CreateEffects::default();
2497        dispatch(
2498            &mut transport,
2499            &mut client,
2500            &mut effects,
2501            "create",
2502            &json!({ "schema": schema(), "securityPreflight": true }),
2503        )
2504        .expect("preflight create");
2505
2506        // Reads and the audit observe protected local data: still gated.
2507        for method in ["previousVersionSnapshot", "previousVersionAudit"] {
2508            let error = dispatch(
2509                &mut transport,
2510                &mut client,
2511                &mut effects,
2512                method,
2513                &json!({}),
2514            )
2515            .expect_err("previous-version reads stay gated during preflight");
2516            assert_eq!(error.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
2517        }
2518        // RFC 0006 runs the discard consumer inside the quiesced window, so the
2519        // command router must permit it (feature off here: a clean no-op).
2520        let discarded = dispatch(
2521            &mut transport,
2522            &mut client,
2523            &mut effects,
2524            "previousVersionDiscard",
2525            &json!({}),
2526        )
2527        .expect("authorized cleanup is allowed during preflight");
2528        assert_eq!(discarded, json!({ "present": false, "discarded": false }));
2529        assert!(client.as_ref().is_some_and(SyncClient::security_preflight));
2530    }
2531
2532    #[test]
2533    fn preflight_refuses_a_replacement_create_without_the_flag() {
2534        let mut transport = NoNetwork::default();
2535        let mut client: Option<SyncClient> = None;
2536        let mut effects = CreateEffects::default();
2537        dispatch(
2538            &mut transport,
2539            &mut client,
2540            &mut effects,
2541            "create",
2542            &json!({ "schema": schema(), "securityPreflight": true }),
2543        )
2544        .expect("preflight create");
2545
2546        // The escape the gate exists to prevent: a plain re-create must fail.
2547        let escape = dispatch(
2548            &mut transport,
2549            &mut client,
2550            &mut effects,
2551            "create",
2552            &json!({ "schema": schema() }),
2553        )
2554        .expect_err("plain create must be refused during preflight");
2555        assert_eq!(escape.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
2556        // The refused create left the preflighted client installed and gated.
2557        let query_error = dispatch(
2558            &mut transport,
2559            &mut client,
2560            &mut effects,
2561            "query",
2562            &json!({ "sql": "SELECT id FROM todos", "params": [] }),
2563        )
2564        .expect_err("protected query stays gated");
2565        assert_eq!(query_error.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
2566
2567        // A preflighted replacement is permitted and stays gated.
2568        dispatch(
2569            &mut transport,
2570            &mut client,
2571            &mut effects,
2572            "create",
2573            &json!({ "schema": schema(), "securityPreflight": true }),
2574        )
2575        .expect("preflighted replacement create");
2576        let still_gated = dispatch(
2577            &mut transport,
2578            &mut client,
2579            &mut effects,
2580            "query",
2581            &json!({ "sql": "SELECT id FROM todos", "params": [] }),
2582        )
2583        .expect_err("replacement stays gated");
2584        assert_eq!(still_gated.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
2585
2586        // A legitimate activation releases the gate; creates behave as today.
2587        dispatch(
2588            &mut transport,
2589            &mut client,
2590            &mut effects,
2591            "activateSecurity",
2592            &json!({}),
2593        )
2594        .expect("activation");
2595        dispatch(
2596            &mut transport,
2597            &mut client,
2598            &mut effects,
2599            "create",
2600            &json!({ "schema": schema() }),
2601        )
2602        .expect("plain create after activation");
2603        let rows = dispatch(
2604            &mut transport,
2605            &mut client,
2606            &mut effects,
2607            "query",
2608            &json!({ "sql": "SELECT id FROM todos", "params": [] }),
2609        )
2610        .expect("active query");
2611        assert_eq!(rows, json!({ "rows": [] }));
2612    }
2613
2614    /// A file-backed replica: the persisted quarantine marker only exists here,
2615    /// which is why the in-memory tests above could not catch this.
2616    fn temp_db_path(tag: &str) -> String {
2617        std::env::temp_dir()
2618            .join(format!(
2619                "syncular-command-{tag}-{}-{:?}.db",
2620                std::process::id(),
2621                std::time::SystemTime::now()
2622                    .duration_since(std::time::UNIX_EPOCH)
2623                    .expect("clock")
2624                    .as_nanos()
2625            ))
2626            .to_string_lossy()
2627            .into_owned()
2628    }
2629
2630    #[test]
2631    fn an_activated_replica_reopens_plainly_after_shutdown() {
2632        // Shutting an ACTIVATED client down is not a quarantine event. The
2633        // teardown barrier used to call `begin_security_preflight`, which now
2634        // persists the gate, so every cleanly closed replica was marked pending
2635        // and its next plain `create` was refused forever — the reopen path
2636        // restores the flag from the marker and the create guard rejects it.
2637        let path = temp_db_path("activated-reopen");
2638        let mut transport = NoNetwork::default();
2639        let mut client: Option<SyncClient> = None;
2640        let mut effects = CreateEffects::default();
2641
2642        dispatch(
2643            &mut transport,
2644            &mut client,
2645            &mut effects,
2646            "create",
2647            &json!({ "schema": schema(), "securityPreflight": true, "dbPath": path }),
2648        )
2649        .expect("preflight create");
2650        dispatch(
2651            &mut transport,
2652            &mut client,
2653            &mut effects,
2654            "activateSecurity",
2655            &json!({}),
2656        )
2657        .expect("activation clears the gate");
2658        dispatch(
2659            &mut transport,
2660            &mut client,
2661            &mut effects,
2662            "shutdown",
2663            &json!({}),
2664        )
2665        .expect("shutdown after activation");
2666
2667        // The replica is active on disk, so a plain re-create must be admitted.
2668        dispatch(
2669            &mut transport,
2670            &mut client,
2671            &mut effects,
2672            "create",
2673            &json!({ "schema": schema(), "dbPath": path }),
2674        )
2675        .expect("an activated replica must reopen plainly after shutdown");
2676        let state = dispatch(
2677            &mut transport,
2678            &mut client,
2679            &mut effects,
2680            "securityLifecycle",
2681            &json!({}),
2682        )
2683        .expect("lifecycle");
2684        assert_eq!(state, json!({ "state": "active" }));
2685
2686        let _ = std::fs::remove_file(&path);
2687    }
2688
2689    #[test]
2690    fn an_unactivated_replica_stays_quarantined_across_shutdown_on_disk() {
2691        // The other half, and the reason the marker exists: a replica that
2692        // entered preflight and never activated must still refuse a plain
2693        // re-create after shutdown, even on a host that rebuilt its handle and
2694        // carries no in-memory flag.
2695        let path = temp_db_path("quarantined-reopen");
2696        let mut transport = NoNetwork::default();
2697        let mut client: Option<SyncClient> = None;
2698        let mut effects = CreateEffects::default();
2699
2700        dispatch(
2701            &mut transport,
2702            &mut client,
2703            &mut effects,
2704            "create",
2705            &json!({ "schema": schema(), "securityPreflight": true, "dbPath": path }),
2706        )
2707        .expect("preflight create");
2708        dispatch(
2709            &mut transport,
2710            &mut client,
2711            &mut effects,
2712            "shutdown",
2713            &json!({}),
2714        )
2715        .expect("shutdown during preflight");
2716
2717        // A fresh host handle: no in-memory pending flag survives here, so only
2718        // the persisted marker can hold the gate.
2719        let mut rebuilt = CreateEffects::default();
2720        let escape = dispatch(
2721            &mut transport,
2722            &mut client,
2723            &mut rebuilt,
2724            "create",
2725            &json!({ "schema": schema(), "dbPath": path }),
2726        )
2727        .expect_err("a quarantined replica must refuse a plain re-create");
2728        assert_eq!(escape.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
2729
2730        let _ = std::fs::remove_file(&path);
2731    }
2732
2733    #[test]
2734    fn preflight_gate_survives_shutdown_before_replacement_creates() {
2735        let mut transport = NoNetwork::default();
2736        let mut client: Option<SyncClient> = None;
2737        let mut effects = CreateEffects::default();
2738        dispatch(
2739            &mut transport,
2740            &mut client,
2741            &mut effects,
2742            "create",
2743            &json!({ "schema": schema(), "securityPreflight": true }),
2744        )
2745        .expect("preflight create");
2746        dispatch(
2747            &mut transport,
2748            &mut client,
2749            &mut effects,
2750            "shutdown",
2751            &json!({}),
2752        )
2753        .expect("shutdown during preflight");
2754        assert!(client.is_none());
2755
2756        // The pending preflight rides `effects` across the empty client slot.
2757        let escape = dispatch(
2758            &mut transport,
2759            &mut client,
2760            &mut effects,
2761            "create",
2762            &json!({ "schema": schema() }),
2763        )
2764        .expect_err("shutdown + plain create must stay refused");
2765        assert_eq!(escape.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
2766
2767        dispatch(
2768            &mut transport,
2769            &mut client,
2770            &mut effects,
2771            "create",
2772            &json!({ "schema": schema(), "securityPreflight": true }),
2773        )
2774        .expect("preflighted re-create");
2775        dispatch(
2776            &mut transport,
2777            &mut client,
2778            &mut effects,
2779            "activateSecurity",
2780            &json!({}),
2781        )
2782        .expect("activation");
2783
2784        // An ACTIVATED client shut down cleanly may be recreated plainly.
2785        dispatch(
2786            &mut transport,
2787            &mut client,
2788            &mut effects,
2789            "shutdown",
2790            &json!({}),
2791        )
2792        .expect("shutdown after activation");
2793        dispatch(
2794            &mut transport,
2795            &mut client,
2796            &mut effects,
2797            "create",
2798            &json!({ "schema": schema() }),
2799        )
2800        .expect("plain create after an activated shutdown");
2801    }
2802
2803    #[test]
2804    fn activate_security_validates_optional_headers_atomically() {
2805        let mut transport = NoNetwork::default();
2806        let mut client: Option<SyncClient> = None;
2807        let mut effects = CreateEffects::default();
2808        dispatch(
2809            &mut transport,
2810            &mut client,
2811            &mut effects,
2812            "create",
2813            &json!({ "schema": schema(), "securityPreflight": true }),
2814        )
2815        .expect("preflight create");
2816
2817        // Invalid header shapes fail loudly and keep the gate closed.
2818        let invalid = dispatch(
2819            &mut transport,
2820            &mut client,
2821            &mut effects,
2822            "activateSecurity",
2823            &json!({ "headers": { "authorization": 7 } }),
2824        )
2825        .expect_err("non-string header must fail");
2826        assert_eq!(invalid.code, "sync.invalid_request");
2827        let lifecycle = dispatch(
2828            &mut transport,
2829            &mut client,
2830            &mut effects,
2831            "securityLifecycle",
2832            &json!({}),
2833        )
2834        .expect("lifecycle");
2835        assert_eq!(lifecycle, json!({ "state": "preflight" }));
2836
2837        // A valid header set activates in one atomic step.
2838        dispatch(
2839            &mut transport,
2840            &mut client,
2841            &mut effects,
2842            "activateSecurity",
2843            &json!({ "headers": { "authorization": "Bearer fresh" } }),
2844        )
2845        .expect("activation with fresh headers");
2846        let lifecycle = dispatch(
2847            &mut transport,
2848            &mut client,
2849            &mut effects,
2850            "securityLifecycle",
2851            &json!({}),
2852        )
2853        .expect("lifecycle");
2854        assert_eq!(lifecycle, json!({ "state": "active" }));
2855    }
2856
2857    #[test]
2858    fn parse_headers_reads_the_full_replacement_set() {
2859        let parsed = parse_headers(&json!({
2860            "authorization": "Bearer fresh",
2861            "x-tenant": "t1"
2862        }))
2863        .expect("valid headers parse");
2864        assert_eq!(
2865            parsed,
2866            vec![
2867                ("authorization".to_owned(), "Bearer fresh".to_owned()),
2868                ("x-tenant".to_owned(), "t1".to_owned())
2869            ]
2870        );
2871        assert!(parse_headers(&json!(["authorization"])).is_err());
2872        assert!(parse_headers(&json!({ "authorization": null })).is_err());
2873    }
2874
2875    #[test]
2876    fn set_headers_validates_replacement_and_respects_preflight() {
2877        let mut transport = NoNetwork::default();
2878        let mut client: Option<SyncClient> = None;
2879        let mut effects = CreateEffects::default();
2880        dispatch(
2881            &mut transport,
2882            &mut client,
2883            &mut effects,
2884            "create",
2885            &json!({ "schema": schema() }),
2886        )
2887        .expect("create");
2888        dispatch(
2889            &mut transport,
2890            &mut client,
2891            &mut effects,
2892            "setHeaders",
2893            &json!({ "headers": { "authorization": "Bearer fresh" } }),
2894        )
2895        .expect("valid replacement");
2896        let invalid = dispatch(
2897            &mut transport,
2898            &mut client,
2899            &mut effects,
2900            "setHeaders",
2901            &json!({ "headers": { "authorization": 7 } }),
2902        )
2903        .expect_err("invalid replacement");
2904        assert_eq!(invalid.code, "sync.invalid_request");
2905
2906        dispatch(
2907            &mut transport,
2908            &mut client,
2909            &mut effects,
2910            "beginSecurityPreflight",
2911            &json!({}),
2912        )
2913        .expect("preflight");
2914        let gated = dispatch(
2915            &mut transport,
2916            &mut client,
2917            &mut effects,
2918            "setHeaders",
2919            &json!({ "headers": {} }),
2920        )
2921        .expect_err("direct rotation must respect preflight");
2922        assert_eq!(gated.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
2923    }
2924
2925    fn schema_v2() -> Value {
2926        json!({
2927            "version": 2,
2928            "tables": [{
2929                "name": "todos",
2930                "columns": [
2931                    { "name": "id", "type": "string", "nullable": false },
2932                    { "name": "list_id", "type": "string", "nullable": false },
2933                    { "name": "note", "type": "string", "nullable": true }
2934                ],
2935                "primaryKey": "id",
2936                "scopes": [{ "pattern": "list:{list_id}", "column": "list_id" }]
2937            }]
2938        })
2939    }
2940
2941    fn previous_version_enabled() -> Value {
2942        json!({ "enabled": true })
2943    }
2944
2945    fn container_path(db_path: &str) -> String {
2946        format!("{db_path}.prev-context")
2947    }
2948
2949    #[test]
2950    fn previous_version_config_parser_matches_ts_keys_and_defaults() {
2951        assert_eq!(parse_previous_version_context(None).expect("absent"), None);
2952        let defaults = parse_previous_version_context(Some(&json!({})))
2953            .expect("empty")
2954            .expect("present");
2955        assert!(!defaults.enabled);
2956        assert_eq!(defaults.max_bytes, 8 * 1024 * 1024);
2957        assert_eq!(defaults.max_rows, 20_000);
2958        assert_eq!(defaults.max_tables, 32);
2959        assert_eq!(defaults.max_row_bytes, 1024 * 1024);
2960        assert_eq!(defaults.max_age_ms, 24 * 60 * 60 * 1000);
2961
2962        let configured = parse_previous_version_context(Some(&json!({
2963            "enabled": true,
2964            "maxBytes": 1024,
2965            "maxRows": 3,
2966            "maxTables": 2,
2967            "maxRowBytes": 5,
2968            "maxAgeMs": 7
2969        })))
2970        .expect("configured")
2971        .expect("present");
2972        assert!(configured.enabled);
2973        assert_eq!(configured.max_bytes, 1024);
2974        assert_eq!(configured.max_rows, 3);
2975        assert_eq!(configured.max_tables, 2);
2976        assert_eq!(configured.max_row_bytes, 5);
2977        assert_eq!(configured.max_age_ms, 7);
2978
2979        // Invalid values fail loudly instead of silently defaulting.
2980        for (value, expected) in [
2981            (json!([]), "must be an object"),
2982            (json!({ "enabled": "yes" }), "enabled must be a boolean"),
2983            (
2984                json!({ "maxBytes": 0 }),
2985                "maxBytes must be a positive safe integer",
2986            ),
2987            (
2988                json!({ "maxRows": -1 }),
2989                "maxRows must be a positive safe integer",
2990            ),
2991            (
2992                json!({ "maxTables": 1.5 }),
2993                "maxTables must be a positive safe integer",
2994            ),
2995            (
2996                json!({ "maxRowBytes": "1" }),
2997                "maxRowBytes must be a positive safe integer",
2998            ),
2999            (
3000                json!({ "maxAgeMs": 0 }),
3001                "maxAgeMs must be a positive safe integer",
3002            ),
3003        ] {
3004            let error = parse_previous_version_context(Some(&value)).expect_err("must fail");
3005            assert_eq!(error.code, "sync.invalid_request", "{value}");
3006            assert!(
3007                error.message.contains(expected),
3008                "{value}: {}",
3009                error.message
3010            );
3011        }
3012    }
3013
3014    #[test]
3015    fn invalid_previous_version_config_fails_create_loudly() {
3016        let mut transport = NoNetwork::default();
3017        let mut client: Option<SyncClient> = None;
3018        let mut effects = CreateEffects::default();
3019        let error = dispatch(
3020            &mut transport,
3021            &mut client,
3022            &mut effects,
3023            "create",
3024            &json!({ "schema": schema(), "previousVersionContext": { "enabled": true, "maxRows": 0 } }),
3025        )
3026        .expect_err("invalid config must fail the create");
3027        assert_eq!(error.code, "sync.invalid_request");
3028        assert!(error.message.contains("maxRows"), "{}", error.message);
3029        assert!(client.is_none(), "no client may be installed");
3030    }
3031
3032    #[test]
3033    fn previous_version_snapshot_reports_not_configured_by_default() {
3034        let mut transport = NoNetwork::default();
3035        let mut client: Option<SyncClient> = None;
3036        let mut effects = CreateEffects::default();
3037        dispatch(
3038            &mut transport,
3039            &mut client,
3040            &mut effects,
3041            "create",
3042            &json!({ "schema": schema() }),
3043        )
3044        .expect("create");
3045        let snapshot = dispatch(
3046            &mut transport,
3047            &mut client,
3048            &mut effects,
3049            "previousVersionSnapshot",
3050            &json!({ "table": "todos" }),
3051        )
3052        .expect("snapshot");
3053        assert_eq!(snapshot["state"], json!("previousVersion"));
3054        assert_eq!(snapshot["available"], json!(false));
3055        assert_eq!(snapshot["reason"], json!("not-configured"));
3056        assert_eq!(snapshot["rows"], json!([]));
3057        assert_eq!(snapshot["truncated"], json!(false));
3058        assert_eq!(snapshot["currentVersion"], json!(1));
3059        let discarded = dispatch(
3060            &mut transport,
3061            &mut client,
3062            &mut effects,
3063            "previousVersionDiscard",
3064            &json!({}),
3065        )
3066        .expect("discard");
3067        assert_eq!(discarded, json!({ "present": false, "discarded": false }));
3068        assert_eq!(
3069            dispatch(
3070                &mut transport,
3071                &mut client,
3072                &mut effects,
3073                "previousVersionAudit",
3074                &json!({}),
3075            )
3076            .expect("audit"),
3077            Value::Null
3078        );
3079    }
3080
3081    #[test]
3082    fn previous_version_commands_round_trip_through_the_dispatcher() {
3083        let path = temp_db_path("previous-version");
3084        let mut transport = NoNetwork::default();
3085        let mut client: Option<SyncClient> = None;
3086        let mut effects = CreateEffects::default();
3087
3088        // A v1 replica with one pending local row and the feature on.
3089        dispatch(
3090            &mut transport,
3091            &mut client,
3092            &mut effects,
3093            "create",
3094            &json!({
3095                "schema": schema(),
3096                "dbPath": path,
3097                "previousVersionContext": previous_version_enabled()
3098            }),
3099        )
3100        .expect("create v1");
3101        client
3102            .as_mut()
3103            .expect("client")
3104            .mutate(vec![Mutation::Upsert {
3105                table: "todos".to_owned(),
3106                values: serde_json::Map::from_iter([
3107                    ("id".to_owned(), json!("t1")),
3108                    ("list_id".to_owned(), json!("l1")),
3109                ]),
3110                base_version: None,
3111            }])
3112            .expect("seed a local row");
3113        dispatch(
3114            &mut transport,
3115            &mut client,
3116            &mut effects,
3117            "shutdown",
3118            &json!({}),
3119        )
3120        .expect("shutdown");
3121
3122        // The bump captures into the sibling file.
3123        dispatch(
3124            &mut transport,
3125            &mut client,
3126            &mut effects,
3127            "create",
3128            &json!({
3129                "schema": schema_v2(),
3130                "dbPath": path,
3131                "previousVersionContext": previous_version_enabled()
3132            }),
3133        )
3134        .expect("create v2");
3135        assert!(
3136            std::path::Path::new(&container_path(&path)).exists(),
3137            "the bump must leave a container file"
3138        );
3139        let status = dispatch(
3140            &mut transport,
3141            &mut client,
3142            &mut effects,
3143            "statusSnapshot",
3144            &json!({}),
3145        )
3146        .expect("status");
3147        assert_eq!(
3148            status["previousVersionContext"]["present"],
3149            json!(true),
3150            "{status}"
3151        );
3152        assert!(
3153            status["previousVersionContext"]["createdAtMs"].is_i64(),
3154            "{status}"
3155        );
3156
3157        let snapshot = dispatch(
3158            &mut transport,
3159            &mut client,
3160            &mut effects,
3161            "previousVersionSnapshot",
3162            &json!({ "table": "todos" }),
3163        )
3164        .expect("snapshot");
3165        assert_eq!(snapshot["state"], json!("previousVersion"));
3166        assert_eq!(snapshot["available"], json!(true));
3167        assert_eq!(snapshot["previousVersion"], json!(1));
3168        assert_eq!(snapshot["currentVersion"], json!(2));
3169        assert_eq!(snapshot["truncated"], json!(false));
3170        assert_eq!(snapshot["rows"][0]["id"], json!("t1"));
3171        assert_eq!(snapshot["rows"][0]["list_id"], json!("l1"));
3172
3173        // Unknown previous table and a bad limit are loud request errors.
3174        let unknown = dispatch(
3175            &mut transport,
3176            &mut client,
3177            &mut effects,
3178            "previousVersionSnapshot",
3179            &json!({ "table": "absent" }),
3180        )
3181        .expect_err("unknown previous table");
3182        assert_eq!(unknown.code, "sync.invalid_request");
3183        let bad_limit = dispatch(
3184            &mut transport,
3185            &mut client,
3186            &mut effects,
3187            "previousVersionSnapshot",
3188            &json!({ "table": "todos", "limit": 0 }),
3189        )
3190        .expect_err("bad limit");
3191        assert_eq!(bad_limit.code, "sync.invalid_request");
3192
3193        // D6: the audit names the pending commit's classification, with no
3194        // envelope and nothing dropped.
3195        let audit = dispatch(
3196            &mut transport,
3197            &mut client,
3198            &mut effects,
3199            "previousVersionAudit",
3200            &json!({}),
3201        )
3202        .expect("audit");
3203        assert_eq!(audit["v"], json!(1));
3204        assert_eq!(audit["fromVersion"], json!(1));
3205        assert_eq!(audit["toVersion"], json!(2));
3206        assert_eq!(audit["pending"], json!(1));
3207        assert_eq!(audit["encodable"], json!(1));
3208        assert_eq!(audit["incompatible"], json!([]));
3209        assert!(audit.get("operations").is_none());
3210
3211        // The executable downgrade step, idempotent.
3212        let discarded = dispatch(
3213            &mut transport,
3214            &mut client,
3215            &mut effects,
3216            "previousVersionDiscard",
3217            &json!({}),
3218        )
3219        .expect("discard");
3220        assert_eq!(discarded, json!({ "present": true, "discarded": true }));
3221        assert!(!std::path::Path::new(&container_path(&path)).exists());
3222        let again = dispatch(
3223            &mut transport,
3224            &mut client,
3225            &mut effects,
3226            "previousVersionDiscard",
3227            &json!({}),
3228        )
3229        .expect("idempotent discard");
3230        assert_eq!(again, json!({ "present": false, "discarded": false }));
3231
3232        // Storage read directly: both metadata records are gone.
3233        let inspect = rusqlite::Connection::open(&path).expect("inspect replica");
3234        let remaining: i64 = inspect
3235            .query_row(
3236                "SELECT COUNT(*) FROM _syncular_meta WHERE key IN (?1, ?2)",
3237                rusqlite::params!["previousVersionContext", "previousVersionAudit"],
3238                |row| row.get(0),
3239            )
3240            .expect("count meta");
3241        assert_eq!(remaining, 0);
3242
3243        let _ = std::fs::remove_file(&path);
3244    }
3245
3246    #[test]
3247    fn previous_version_ttl_expires_at_read_through_the_dispatcher() {
3248        let path = temp_db_path("previous-version-ttl");
3249        let mut transport = NoNetwork::default();
3250        let mut client: Option<SyncClient> = None;
3251        let mut effects = CreateEffects::default();
3252        dispatch(
3253            &mut transport,
3254            &mut client,
3255            &mut effects,
3256            "create",
3257            &json!({
3258                "schema": schema(),
3259                "dbPath": path,
3260                "previousVersionContext": previous_version_enabled()
3261            }),
3262        )
3263        .expect("create v1");
3264        dispatch(
3265            &mut transport,
3266            &mut client,
3267            &mut effects,
3268            "shutdown",
3269            &json!({}),
3270        )
3271        .expect("shutdown");
3272
3273        // The injected clock is two seconds past the capture, with a TTL far
3274        // larger than the boot gap but far smaller than the skew, so the boot
3275        // reconcile keeps the container and the read expires it.
3276        let now_ms = std::time::SystemTime::now()
3277            .duration_since(std::time::UNIX_EPOCH)
3278            .expect("clock")
3279            .as_millis() as i64
3280            + 2_000;
3281        dispatch(
3282            &mut transport,
3283            &mut client,
3284            &mut effects,
3285            "create",
3286            &json!({
3287                "schema": schema_v2(),
3288                "dbPath": path,
3289                "nowMs": now_ms,
3290                "previousVersionContext": { "enabled": true, "maxAgeMs": 500 }
3291            }),
3292        )
3293        .expect("create v2");
3294        assert!(std::path::Path::new(&container_path(&path)).exists());
3295        let snapshot = dispatch(
3296            &mut transport,
3297            &mut client,
3298            &mut effects,
3299            "previousVersionSnapshot",
3300            &json!({ "table": "todos" }),
3301        )
3302        .expect("snapshot");
3303        assert_eq!(snapshot["available"], json!(false));
3304        assert_eq!(snapshot["reason"], json!("expired"));
3305        assert_eq!(snapshot["rows"], json!([]));
3306        // The TTL discards, and the FILE is what must be gone.
3307        assert!(!std::path::Path::new(&container_path(&path)).exists());
3308
3309        let _ = std::fs::remove_file(&path);
3310    }
3311}