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::{
23    ClientDiagnosticsRequest, ClientLimits, CommandEffects, CommitOutcomeQuery,
24    LocalDataPurgeInput, LocalDataRebootstrapInput, Mutation, ResolveCommitOutcomeInput,
25    SyncClient, Transport, WindowBase, WindowCoverage,
26};
27
28// -- bytes <-> {"$bytes": hex} (the driver-protocol byte envelope) ----------
29
30pub fn bytes_to_hex(bytes: &[u8]) -> String {
31    syncular_client::values::bytes_to_hex(bytes)
32}
33
34pub fn hex_to_bytes(hex: &str) -> Result<Vec<u8>, String> {
35    syncular_client::values::hex_to_bytes(hex)
36}
37
38pub fn bytes_value(bytes: &[u8]) -> Value {
39    Value::Object(serde_json::Map::from_iter([(
40        "$bytes".to_owned(),
41        Value::from(bytes_to_hex(bytes)),
42    )]))
43}
44
45pub fn value_bytes(value: Option<&Value>) -> Result<Vec<u8>, String> {
46    let hex = value
47        .and_then(|v| v.get("$bytes"))
48        .and_then(Value::as_str)
49        .ok_or_else(|| "expected a {\"$bytes\": hex} value".to_owned())?;
50    hex_to_bytes(hex)
51}
52
53/// The `(code, message)` pair the driver protocol carries in an `error`.
54pub type CommandError = (String, String);
55
56/// Parsed side effects of a `create` command that the host must apply to its
57/// own transport/clock (the router stays transport-agnostic). The client is
58/// already installed into the `Option<SyncClient>` slot by `dispatch`.
59#[derive(Debug, Default, Clone)]
60pub struct CreateEffects {
61    /// §5.4 capability the harness/host announced for its endpoints — the
62    /// host sets its transport's `supports_url_fetch` accordingly.
63    pub signed_urls: bool,
64    /// True while a security preflight is pending: a preflighted client was
65    /// installed (or entered preflight, or was shut down mid-preflight) and
66    /// `activateSecurity` has yet to complete. `dispatch` maintains this so a
67    /// replacement `create` without the `securityPreflight` flag is refused
68    /// across `shutdown`, where the client slot is empty, on a host that reuses
69    /// one `CreateEffects` across creates (the Tauri plugin, the conformance
70    /// shim, the bench harness).
71    ///
72    /// A host that allocates a fresh `CreateEffects` per create — the React
73    /// Native native module rebuilds its FFI handle on every `create` — starts
74    /// each create with this flag clear. For those hosts the persisted marker
75    /// in the client core carries the gate: a file-backed replica reopens in
76    /// preflight, and the `create` path refuses a plain re-create against it.
77    /// This in-memory flag covers the same-handle case where no file persists
78    /// the marker.
79    pub security_preflight_pending: bool,
80}
81
82fn client_err(message: String) -> CommandError {
83    let code = message
84        .split_once(':')
85        .map(|(candidate, _)| candidate)
86        .filter(|candidate| candidate.starts_with("client.") || candidate.starts_with("sync."))
87        .unwrap_or("client.failed");
88    (code.to_owned(), message)
89}
90
91fn need_client(client: &mut Option<SyncClient>) -> Result<&mut SyncClient, CommandError> {
92    client
93        .as_mut()
94        .ok_or_else(|| client_err("no client instance created".to_owned()))
95}
96
97pub fn parse_limits(value: Option<&Value>) -> ClientLimits {
98    let mut limits = ClientLimits::default();
99    let Some(object) = value.and_then(Value::as_object) else {
100        return limits;
101    };
102    limits.limit_commits = object
103        .get("limitCommits")
104        .and_then(Value::as_i64)
105        .map(|v| v as i32);
106    limits.limit_snapshot_rows = object
107        .get("limitSnapshotRows")
108        .and_then(Value::as_i64)
109        .map(|v| v as i32);
110    limits.max_snapshot_pages = object
111        .get("maxSnapshotPages")
112        .and_then(Value::as_i64)
113        .map(|v| v as i32);
114    limits.accept = object
115        .get("accept")
116        .and_then(Value::as_u64)
117        .map(|v| v as u8);
118    limits.blob_cache_max_bytes = object.get("blobCacheMaxBytes").and_then(Value::as_i64);
119    limits.outcome_retention_max_entries = object
120        .get("outcomeRetentionMaxEntries")
121        .and_then(Value::as_u64)
122        .map(|value| value as usize);
123    limits
124}
125
126/// §5.11: parse the `encryption` config into the client's portable keyring.
127/// Shape: `{ keys: { "<keyId>": {"$bytes": "<hex>"} },
128/// keyIdColumns: { "<table>": "<column>" } }`. Keys are 32 bytes.
129pub fn parse_encryption(
130    value: &Value,
131) -> Result<syncular_client::values::EncryptionConfig, String> {
132    let mut config = syncular_client::values::EncryptionConfig::default();
133    let Some(keys) = value.get("keys").and_then(Value::as_object) else {
134        return Ok(config);
135    };
136    for (key_id, key_val) in keys {
137        let bytes =
138            value_bytes(Some(key_val)).map_err(|e| format!("encryption key {key_id:?}: {e}"))?;
139        if bytes.len() != 32 {
140            return Err(format!(
141                "encryption key {key_id:?} must be 32 bytes, got {}",
142                bytes.len()
143            ));
144        }
145        config.keys.insert(key_id.clone(), bytes);
146    }
147    if let Some(columns) = value.get("keyIdColumns") {
148        let columns = columns
149            .as_object()
150            .ok_or_else(|| "encryption keyIdColumns must be an object".to_owned())?;
151        for (table, column) in columns {
152            let column = column.as_str().ok_or_else(|| {
153                format!("encryption keyIdColumns entry for {table:?} must be a string")
154            })?;
155            if column.is_empty() {
156                return Err(format!(
157                    "encryption keyIdColumns entry for {table:?} must not be empty"
158                ));
159            }
160            config
161                .key_id_columns
162                .insert(table.clone(), column.to_owned());
163        }
164    }
165    Ok(config)
166}
167
168/// Parse an `activateSecurity` (or rotation) `headers` param: an object of
169/// string values, replacing the transport's FULL header set.
170/// The router validates the shape at the shared chokepoint; each host applies
171/// the parsed set to its own transport (the router stays transport-agnostic).
172pub fn parse_headers(value: &Value) -> Result<Vec<(String, String)>, String> {
173    let object = value.as_object().ok_or_else(|| {
174        "sync.invalid_request: headers must be an object of string values".to_owned()
175    })?;
176    let mut headers = Vec::with_capacity(object.len());
177    for (name, value) in object {
178        let value = value
179            .as_str()
180            .ok_or_else(|| format!("sync.invalid_request: header {name:?} must be a string"))?;
181        headers.push((name.clone(), value.to_owned()));
182    }
183    Ok(headers)
184}
185
186pub fn parse_mutations(value: Option<&Value>) -> Result<Vec<Mutation>, String> {
187    let list = value
188        .and_then(Value::as_array)
189        .ok_or_else(|| "mutations must be a list".to_owned())?;
190    let mut out = Vec::with_capacity(list.len());
191    for entry in list {
192        let op = entry
193            .get("op")
194            .and_then(Value::as_str)
195            .ok_or_else(|| "mutation missing op".to_owned())?;
196        let table = entry
197            .get("table")
198            .and_then(Value::as_str)
199            .ok_or_else(|| "mutation missing table".to_owned())?
200            .to_owned();
201        let base_version = entry.get("baseVersion").and_then(Value::as_i64);
202        match op {
203            "upsert" => {
204                let mut values = entry
205                    .get("values")
206                    .and_then(Value::as_object)
207                    .cloned()
208                    .ok_or_else(|| "upsert missing values".to_owned())?;
209                decode_bigint_members(&mut values)?;
210                out.push(Mutation::Upsert {
211                    table,
212                    values,
213                    base_version,
214                });
215            }
216            "delete" => {
217                let row_id = entry
218                    .get("rowId")
219                    .and_then(Value::as_str)
220                    .ok_or_else(|| "delete missing rowId".to_owned())?
221                    .to_owned();
222                out.push(Mutation::Delete {
223                    table,
224                    row_id,
225                    base_version,
226                });
227            }
228            other => return Err(format!("unknown mutation op {other:?}")),
229        }
230    }
231    Ok(out)
232}
233
234fn decode_bigint_members(values: &mut serde_json::Map<String, Value>) -> Result<(), String> {
235    for value in values.values_mut() {
236        let Some(decimal) = value.get("$bigint").and_then(Value::as_str) else {
237            continue;
238        };
239        let integer = decimal
240            .parse::<i64>()
241            .map_err(|_| format!("bigint value {decimal:?} is outside SQLite's i64 range"))?;
242        *value = Value::from(integer);
243    }
244    Ok(())
245}
246
247fn scopes_from_params(value: Option<&Value>) -> Result<Vec<(String, Vec<String>)>, String> {
248    match value {
249        Some(v) => syncular_client::values::json_to_scope_map(v),
250        None => Ok(Vec::new()),
251    }
252}
253
254/// §4.8: parse a window base descriptor `{ table, variable, fixedScopes?,
255/// params? }` from a command's `base` param.
256fn window_base_from_params(value: Option<&Value>) -> Result<WindowBase, String> {
257    let object = value
258        .and_then(Value::as_object)
259        .ok_or_else(|| "setWindow/windowState missing base object".to_owned())?;
260    let table = object
261        .get("table")
262        .and_then(Value::as_str)
263        .ok_or_else(|| "window base missing table".to_owned())?
264        .to_owned();
265    let variable = object
266        .get("variable")
267        .and_then(Value::as_str)
268        .ok_or_else(|| "window base missing variable".to_owned())?
269        .to_owned();
270    let fixed_scopes = scopes_from_params(object.get("fixedScopes"))?;
271    let params = object
272        .get("params")
273        .and_then(Value::as_str)
274        .map(str::to_owned);
275    Ok(WindowBase {
276        table,
277        variable,
278        fixed_scopes,
279        params,
280    })
281}
282
283/// §5.10.5: parse the common `(table, rowId, column, name)` target of a crdt
284/// command. `name` selects the shared type inside the doc (default `"text"`,
285/// matching the TS `YjsColumn.text()` default).
286#[cfg(feature = "crdt-yjs")]
287fn crdt_target(params: &Value) -> Result<(String, String, String, String), CommandError> {
288    let table = params
289        .get("table")
290        .and_then(Value::as_str)
291        .ok_or_else(|| client_err("crdt command missing table".to_owned()))?
292        .to_owned();
293    let row_id = params
294        .get("rowId")
295        .and_then(Value::as_str)
296        .ok_or_else(|| client_err("crdt command missing rowId".to_owned()))?
297        .to_owned();
298    let column = params
299        .get("column")
300        .and_then(Value::as_str)
301        .ok_or_else(|| client_err("crdt command missing column".to_owned()))?
302        .to_owned();
303    let name = params
304        .get("name")
305        .and_then(Value::as_str)
306        .unwrap_or("text")
307        .to_owned();
308    Ok((table, row_id, column, name))
309}
310
311/// Dispatch one command against the client instance over `transport`.
312///
313/// The `create` command installs a fresh `SyncClient` into `client` and
314/// returns its parsed `CreateEffects` in the `Ok` result via `effects`; every
315/// other command mutates the existing instance. Errors come back as the
316/// driver-protocol `(code, message)` pair.
317///
318/// Generic over `T: Transport` so the shim (host-inverted transport) and the
319/// FFI core (native HTTP+WS transport) share this exact router.
320pub fn dispatch<T: Transport>(
321    transport: &mut T,
322    client: &mut Option<SyncClient>,
323    effects: &mut CreateEffects,
324    method: &str,
325    params: &Value,
326) -> Result<Value, CommandError> {
327    if method == "create" {
328        // Fail-closed against a compromised webview re-issuing `create` (or
329        // `shutdown` + `create`) WITHOUT the securityPreflight flag to exit
330        // the gate: while a preflight is pending — on the live client, or
331        // carried in `effects` across a `shutdown` — a replacement create must
332        // itself request securityPreflight; the gate opens only through
333        // activateSecurity.
334        let requests_preflight = params
335            .get("securityPreflight")
336            .and_then(Value::as_bool)
337            .unwrap_or(false);
338        let preflight_engaged = client.as_ref().map_or(
339            effects.security_preflight_pending,
340            SyncClient::security_preflight,
341        );
342        if preflight_engaged && !requests_preflight {
343            return Err((
344                syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE.to_owned(),
345                "the local replica is in security preflight; a replacement create must itself request securityPreflight, and protected data opens only after activateSecurity".to_owned(),
346            ));
347        }
348    } else {
349        let allowed_during_preflight = matches!(
350            method,
351            "securityLifecycle"
352                | "beginSecurityPreflight"
353                | "activateSecurity"
354                | "purgeLocalData"
355                | "localRevision"
356                | "statusSnapshot"
357                | "shutdown"
358        );
359        if client
360            .as_ref()
361            .is_some_and(|running| running.security_preflight())
362            && !allowed_during_preflight
363        {
364            return Err((
365                syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE.to_owned(),
366                "the local replica is in security preflight; complete quarantine checks and call activateSecurity before accessing protected data".to_owned(),
367            ));
368        }
369    }
370    match method {
371        "create" => {
372            let client_id = params
373                .get("clientId")
374                .and_then(Value::as_str)
375                .map(str::to_owned);
376            let schema = params
377                .get("schema")
378                .ok_or_else(|| client_err("create missing schema".to_owned()))?;
379            let limits = parse_limits(params.get("limits"));
380            // §native: a `dbPath` installs a file-backed rusqlite connection so
381            // native hosts (Tauri plugin, FFI file variant) persist across
382            // restarts; absent it, the default in-memory core (the shim's mode).
383            let mut instance = match params.get("dbPath").and_then(Value::as_str) {
384                Some(path) => SyncClient::open_path_with_identity(client_id, schema, limits, path)
385                    .map_err(client_err)?,
386                None => {
387                    SyncClient::new_with_identity(client_id, schema, limits).map_err(client_err)?
388                }
389            };
390            // Harness clock pin (§5.4 expiry runs on the virtual clock).
391            if let Some(now_ms) = params.get("nowMs").and_then(Value::as_i64) {
392                instance.set_now_ms(now_ms);
393            }
394            // §5.4 capability of the host endpoint set (accept bit 3) — the
395            // host applies it to its own transport.
396            effects.signed_urls = params
397                .get("signedUrls")
398                .and_then(Value::as_bool)
399                .unwrap_or(false);
400            // §5.11: install client-side encryption keys. Shape:
401            // { encryption: { keys: { "<keyId>": {"$bytes": "<hex>"} },
402            //                 keyIdColumns: { "<table>": "<column>" } } }.
403            let security_preflight = params
404                .get("securityPreflight")
405                .and_then(Value::as_bool)
406                .unwrap_or(false);
407            // A file-backed replica reopens in preflight when its persisted
408            // quarantine marker is set (client core restores it). Refuse a plain
409            // re-create so a rebuilt host handle — the React Native native module
410            // tears its FFI handle down on every create — cannot downgrade a
411            // quarantined replica to active. A create that itself requests
412            // securityPreflight, or an activated replica whose marker cleared,
413            // proceeds.
414            if instance.security_preflight() && !security_preflight {
415                return Err((
416                    syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE.to_owned(),
417                    "the local replica is in security preflight; a replacement create must itself request securityPreflight, and protected data opens only after activateSecurity".to_owned(),
418                ));
419            }
420            if security_preflight && params.get("encryption").is_some() {
421                return Err(client_err(
422                    "sync.invalid_request: securityPreflight and encryption are mutually exclusive; install keys with activateSecurity after preflight"
423                        .to_owned(),
424                ));
425            }
426            if let Some(enc) = params.get("encryption") {
427                let config = parse_encryption(enc).map_err(client_err)?;
428                instance.set_encryption(config);
429            }
430            if security_preflight {
431                instance.begin_security_preflight();
432            }
433            effects.security_preflight_pending = security_preflight;
434            *client = Some(instance);
435            Ok(json!({}))
436        }
437        "securityLifecycle" => Ok(json!({
438            "state": need_client(client)?.security_lifecycle()
439        })),
440        "beginSecurityPreflight" => {
441            let running = need_client(client)?;
442            running.disconnect_realtime(transport);
443            running.begin_security_preflight();
444            effects.security_preflight_pending = true;
445            Ok(json!({}))
446        }
447        "activateSecurity" => {
448            let encryption = match params.get("encryption") {
449                Some(value) => parse_encryption(value).map_err(client_err)?,
450                None => syncular_client::values::EncryptionConfig::default(),
451            };
452            // Optional fresh transport headers ride the activation atomically,
453            // so a preflight that outlives the boot token starts its first
454            // sync round with valid credentials. Validated here at the shared
455            // chokepoint (invalid input keeps the gate closed); the host
456            // applies the parsed set to its own transport.
457            if let Some(headers) = params.get("headers") {
458                parse_headers(headers).map_err(client_err)?;
459            }
460            need_client(client)?
461                .activate_security(encryption)
462                .map_err(client_err)?;
463            effects.security_preflight_pending = false;
464            Ok(json!({}))
465        }
466        "setHeaders" => {
467            let headers = params
468                .get("headers")
469                .ok_or_else(|| client_err("setHeaders missing headers".to_owned()))?;
470            parse_headers(headers).map_err(client_err)?;
471            Ok(json!({}))
472        }
473        "shutdown" => {
474            if let Some(running) = client.as_mut() {
475                // Capture the gate state BEFORE the shutdown barrier flips the
476                // client into preflight: an unactivated preflight stays
477                // pending across the shutdown, while an activated client may
478                // be recreated plainly afterwards.
479                effects.security_preflight_pending = running.security_preflight();
480                running.disconnect_realtime(transport);
481                // Teardown barrier only: an activated client that shuts down
482                // cleanly must not leave a durable quarantine mark, or its next
483                // plain create would be refused forever.
484                running.seal_security_on_teardown();
485            }
486            *client = None;
487            Ok(json!({}))
488        }
489        "subscribe" => {
490            let id = params
491                .get("id")
492                .and_then(Value::as_str)
493                .ok_or_else(|| client_err("subscribe missing id".to_owned()))?
494                .to_owned();
495            let table = params
496                .get("table")
497                .and_then(Value::as_str)
498                .ok_or_else(|| client_err("subscribe missing table".to_owned()))?
499                .to_owned();
500            let scopes = scopes_from_params(params.get("scopes")).map_err(client_err)?;
501            let sub_params = params
502                .get("params")
503                .and_then(Value::as_str)
504                .map(str::to_owned);
505            need_client(client)?
506                .subscribe(id, table, scopes, sub_params)
507                .map_err(client_err)?;
508            Ok(json!({}))
509        }
510        "unsubscribe" => {
511            let id = params
512                .get("id")
513                .and_then(Value::as_str)
514                .ok_or_else(|| client_err("unsubscribe missing id".to_owned()))?;
515            need_client(client)?.unsubscribe(id);
516            Ok(json!({}))
517        }
518        "setWindow" => {
519            let base = window_base_from_params(params.get("base")).map_err(client_err)?;
520            let units: Vec<String> = params
521                .get("units")
522                .and_then(Value::as_array)
523                .map(|arr| {
524                    arr.iter()
525                        .filter_map(|v| v.as_str().map(str::to_owned))
526                        .collect()
527                })
528                .unwrap_or_default();
529            let command_effects = need_client(client)?
530                .set_window(&base, &units)
531                .map_err(client_err)?;
532            Ok(json!({ "effects": command_effects }))
533        }
534        "windowState" => {
535            let base = window_base_from_params(params.get("base")).map_err(client_err)?;
536            let state = need_client(client)?.window_state(&base);
537            Ok(json!({ "units": state.units, "pending": state.pending }))
538        }
539        "timeWindowSugar" => {
540            let created_at_ms = params
541                .get("createdAtMs")
542                .and_then(Value::as_i64)
543                .ok_or_else(|| client_err("timeWindowSugar missing createdAtMs".to_owned()))?;
544            let count = params
545                .get("count")
546                .and_then(Value::as_u64)
547                .and_then(|value| usize::try_from(value).ok())
548                .ok_or_else(|| client_err("timeWindowSugar missing count".to_owned()))?;
549            let now_ms = params
550                .get("nowMs")
551                .and_then(Value::as_i64)
552                .ok_or_else(|| client_err("timeWindowSugar missing nowMs".to_owned()))?;
553            if params.get("unit").and_then(Value::as_str) != Some("month") {
554                return Err(client_err(
555                    "sync.invalid_request: timeWindowSugar requires the month unit".to_owned(),
556                ));
557            }
558            let unit = syncular_client::TimeBucketUnit::Month;
559            Ok(json!({
560                "bucket": syncular_client::creation_time_bucket(created_at_ms, unit)
561                    .map_err(client_err)?,
562                "units": syncular_client::last(count, unit, now_ms).map_err(client_err)?,
563            }))
564        }
565        "mutate" => {
566            let mutations = parse_mutations(params.get("mutations")).map_err(client_err)?;
567            let id = need_client(client)?.mutate(mutations).map_err(client_err)?;
568            Ok(json!({
569                "clientCommitId": id,
570                "effects": CommandEffects::interactive()
571            }))
572        }
573        "patch" => {
574            let table = params
575                .get("table")
576                .and_then(Value::as_str)
577                .ok_or_else(|| client_err("patch missing table".to_owned()))?;
578            let row_id = params
579                .get("rowId")
580                .and_then(Value::as_str)
581                .ok_or_else(|| client_err("patch missing rowId".to_owned()))?;
582            let mut partial = params
583                .get("partial")
584                .and_then(Value::as_object)
585                .cloned()
586                .ok_or_else(|| client_err("patch missing partial object".to_owned()))?;
587            decode_bigint_members(&mut partial).map_err(client_err)?;
588            let base_version = params.get("baseVersion").and_then(Value::as_i64);
589            let id = need_client(client)?
590                .patch(table, row_id, partial, base_version)
591                .map_err(client_err)?;
592            Ok(json!({
593                "clientCommitId": id,
594                "effects": CommandEffects::interactive()
595            }))
596        }
597        "purgeLocalData" => {
598            let input: LocalDataPurgeInput =
599                serde_json::from_value(params.get("input").cloned().ok_or_else(|| {
600                    client_err("sync.invalid_request: purgeLocalData missing input".to_owned())
601                })?)
602                .map_err(|error| {
603                    client_err(format!(
604                        "sync.invalid_request: invalid purgeLocalData input: {error}"
605                    ))
606                })?;
607            let result = need_client(client)?
608                .purge_local_data(&input)
609                .map_err(client_err)?;
610            serde_json::to_value(result).map_err(|error| client_err(error.to_string()))
611        }
612        "rebootstrapLocalData" => {
613            let input: LocalDataRebootstrapInput =
614                serde_json::from_value(params.get("input").cloned().ok_or_else(|| {
615                    client_err(
616                        "sync.invalid_request: rebootstrapLocalData missing input".to_owned(),
617                    )
618                })?)
619                .map_err(|error| {
620                    client_err(format!(
621                        "sync.invalid_request: invalid rebootstrapLocalData input: {error}"
622                    ))
623                })?;
624            let result = need_client(client)?
625                .rebootstrap_local_data(&input)
626                .map_err(client_err)?;
627            Ok(json!({
628                "alreadyApplied": result.already_applied,
629                "retainedCommits": result.retained_commits,
630                "resetSubscriptions": result.reset_subscriptions,
631                "effects": if result.already_applied {
632                    CommandEffects::none()
633                } else {
634                    CommandEffects::interactive()
635                }
636            }))
637        }
638        "sync" => {
639            let outcome = need_client(client)?.sync(transport);
640            Ok(outcome.to_json())
641        }
642        "syncUntilIdle" => {
643            let max_rounds = params
644                .get("maxRounds")
645                .and_then(Value::as_u64)
646                .map(|v| v as u32);
647            let outcome = need_client(client)?.sync_until_idle(transport, max_rounds);
648            Ok(outcome.to_json())
649        }
650        "readRows" => {
651            let table = params
652                .get("table")
653                .and_then(Value::as_str)
654                .ok_or_else(|| client_err("readRows missing table".to_owned()))?;
655            let rows = need_client(client)?.read_rows(table).map_err(client_err)?;
656            Ok(json!({ "rows": rows }))
657        }
658        "query" => {
659            // The React `useSyncQuery` live-query fast path: arbitrary read-only
660            // SQL over the local visible tables/views. Params ride as the driver
661            // value forms (bytes as `{"$bytes": hex}`); rows come back the same.
662            let sql = params
663                .get("sql")
664                .and_then(Value::as_str)
665                .ok_or_else(|| client_err("query missing sql".to_owned()))?;
666            let bind = match params.get("params") {
667                Some(Value::Array(list)) => list.as_slice(),
668                None | Some(Value::Null) => &[],
669                Some(_) => return Err(client_err("query params must be a list".to_owned())),
670            };
671            let rows = need_client(client)?.query(sql, bind).map_err(client_err)?;
672            Ok(Value::Object(serde_json::Map::from_iter([(
673                "rows".to_owned(),
674                Value::Array(rows.into_iter().map(Value::Object).collect()),
675            )])))
676        }
677        "querySnapshot" => {
678            let sql = params
679                .get("sql")
680                .and_then(Value::as_str)
681                .ok_or_else(|| client_err("querySnapshot missing sql".to_owned()))?;
682            let bind = match params.get("params") {
683                Some(Value::Array(list)) => list.as_slice(),
684                None | Some(Value::Null) => &[],
685                Some(_) => {
686                    return Err(client_err("querySnapshot params must be a list".to_owned()))
687                }
688            };
689            let mut coverage = Vec::new();
690            for entry in params
691                .get("coverage")
692                .and_then(Value::as_array)
693                .into_iter()
694                .flatten()
695            {
696                let base = window_base_from_params(entry.get("base")).map_err(client_err)?;
697                let units = entry
698                    .get("units")
699                    .and_then(Value::as_array)
700                    .map(|values| {
701                        values
702                            .iter()
703                            .filter_map(|value| value.as_str().map(str::to_owned))
704                            .collect()
705                    })
706                    .unwrap_or_default();
707                coverage.push(WindowCoverage { base, units });
708            }
709            let mut snapshot = need_client(client)?
710                .query_snapshot(sql, bind, &coverage)
711                .map_err(client_err)?;
712            let rows = std::mem::take(&mut snapshot.rows);
713            let mut result = serde_json::to_value(snapshot).expect("snapshot serializes");
714            result["rows"] = Value::Array(rows.into_iter().map(Value::Object).collect());
715            Ok(result)
716        }
717        "localRevision" => Ok(json!({
718            "revision": need_client(client)?.local_revision().to_string()
719        })),
720        "statusSnapshot" => Ok(serde_json::to_value(need_client(client)?.status_snapshot())
721            .expect("status serializes")),
722        "diagnosticsSnapshot" => {
723            let request = serde_json::from_value::<ClientDiagnosticsRequest>(params.clone())
724                .map_err(|error| {
725                    client_err(format!(
726                        "sync.invalid_request: invalid diagnostics request: {error}"
727                    ))
728                })?;
729            let snapshot = need_client(client)?
730                .diagnostics_snapshot(&request)
731                .map_err(client_err)?;
732            Ok(serde_json::to_value(snapshot).expect("diagnostics serialize"))
733        }
734        // Conformance/debug drains. Production hosts normally drain these
735        // immediately after every command, but exposing the exact core output
736        // here lets both client implementations consume one observation
737        // vector catalog without bridge inference.
738        "progressSnapshot" => Ok(
739            serde_json::to_value(need_client(client)?.progress().snapshot())
740                .expect("progress JSON"),
741        ),
742        "drainChangeBatches" => Ok(json!({
743            "batches": need_client(client)?.drain_change_batches()
744        })),
745        "drainSyncIntents" => Ok(json!({
746            "intents": need_client(client)?.drain_sync_intents()
747        })),
748        // -- §5.10.5 native CRDT (the `crdt-yjs` feature) -----------------------
749        // Thin forwards to the client core's yrs helpers. The command surface
750        // stays present-but-unavailable in a lean build: without the feature
751        // these fail loudly (`client.crdt_unavailable`) rather than being an
752        // unknown method, so a wrapper's typed method gives a clear error.
753        #[cfg(feature = "crdt-yjs")]
754        "crdtText" => {
755            let (table, row_id, column, name) = crdt_target(params)?;
756            let text = need_client(client)?
757                .crdt_text(&table, &row_id, &column, &name)
758                .map_err(client_err)?;
759            Ok(json!({ "text": text }))
760        }
761        #[cfg(feature = "crdt-yjs")]
762        "crdtInsertText" => {
763            let (table, row_id, column, name) = crdt_target(params)?;
764            let index = params
765                .get("index")
766                .and_then(Value::as_u64)
767                .ok_or_else(|| client_err("crdtInsertText missing index".to_owned()))?
768                as u32;
769            let value = params
770                .get("value")
771                .and_then(Value::as_str)
772                .ok_or_else(|| client_err("crdtInsertText missing value".to_owned()))?;
773            let id = need_client(client)?
774                .crdt_insert_text(&table, &row_id, &column, &name, index, value)
775                .map_err(client_err)?;
776            Ok(json!({ "clientCommitId": id }))
777        }
778        #[cfg(feature = "crdt-yjs")]
779        "crdtDeleteText" => {
780            let (table, row_id, column, name) = crdt_target(params)?;
781            let index = params
782                .get("index")
783                .and_then(Value::as_u64)
784                .ok_or_else(|| client_err("crdtDeleteText missing index".to_owned()))?
785                as u32;
786            let len = params
787                .get("len")
788                .and_then(Value::as_u64)
789                .ok_or_else(|| client_err("crdtDeleteText missing len".to_owned()))?
790                as u32;
791            let id = need_client(client)?
792                .crdt_delete_text(&table, &row_id, &column, &name, index, len)
793                .map_err(client_err)?;
794            Ok(json!({ "clientCommitId": id }))
795        }
796        #[cfg(feature = "crdt-yjs")]
797        "crdtApplyUpdate" => {
798            let (table, row_id, column, _name) = crdt_target(params)?;
799            let update = value_bytes(params.get("update")).map_err(client_err)?;
800            let id = need_client(client)?
801                .crdt_apply_update(&table, &row_id, &column, &update)
802                .map_err(client_err)?;
803            Ok(json!({ "clientCommitId": id }))
804        }
805        #[cfg(not(feature = "crdt-yjs"))]
806        "crdtText" | "crdtInsertText" | "crdtDeleteText" | "crdtApplyUpdate" => Err((
807            "client.crdt_unavailable".to_owned(),
808            "native CRDT support requires the `crdt-yjs` feature (§5.10.5)".to_owned(),
809        )),
810
811        "uploadBlob" => {
812            let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
813            let media_type = params
814                .get("mediaType")
815                .and_then(Value::as_str)
816                .map(str::to_owned);
817            let name = params
818                .get("name")
819                .and_then(Value::as_str)
820                .map(str::to_owned);
821            let reference = need_client(client)?
822                .upload_blob(&bytes, media_type, name)
823                .map_err(client_err)?;
824            Ok(json!({ "ref": reference }))
825        }
826        "fetchBlob" => {
827            let blob = params
828                .get("blob")
829                .and_then(Value::as_str)
830                .ok_or_else(|| client_err("fetchBlob missing blob".to_owned()))?
831                .to_owned();
832            // fetch_blob_bytes returns (code, message) so the server's blob.*
833            // code reaches the caller (§5.9.5 cross-scope probe). The command
834            // boundary owns the JSON byte encoding used by native bindings.
835            let blob = need_client(client)?.fetch_blob_bytes(transport, &blob)?;
836            let mut value = serde_json::Map::new();
837            value.insert("blobId".to_owned(), Value::from(blob.blob_id));
838            value.insert("byteLength".to_owned(), Value::from(blob.byte_length));
839            value.insert("bytes".to_owned(), bytes_value(&blob.bytes));
840            if let Some(media_type) = blob.media_type {
841                value.insert("mediaType".to_owned(), Value::from(media_type));
842            }
843            Ok(Value::Object(serde_json::Map::from_iter([(
844                "blob".to_owned(),
845                Value::Object(value),
846            )])))
847        }
848        "conflicts" => {
849            let conflicts = need_client(client)?.conflicts().to_vec();
850            Ok(json!({ "conflicts": conflicts }))
851        }
852        "rejections" => {
853            let rejections = need_client(client)?.rejections().to_vec();
854            Ok(json!({ "rejections": rejections }))
855        }
856        "commitOutcome" => {
857            let client_commit_id = params
858                .get("clientCommitId")
859                .and_then(Value::as_str)
860                .ok_or_else(|| {
861                    client_err(
862                        "sync.invalid_request: commitOutcome missing clientCommitId".to_owned(),
863                    )
864                })?;
865            let outcome = need_client(client)?
866                .commit_outcome(client_commit_id)
867                .map_err(client_err)?;
868            Ok(json!({ "outcome": outcome }))
869        }
870        "commitOutcomes" => {
871            let query = serde_json::from_value::<CommitOutcomeQuery>(
872                params.get("query").cloned().unwrap_or_else(|| json!({})),
873            )
874            .map_err(|error| {
875                client_err(format!(
876                    "sync.invalid_request: invalid commit outcome query: {error}"
877                ))
878            })?;
879            let outcomes = need_client(client)?
880                .commit_outcomes(query)
881                .map_err(client_err)?;
882            Ok(json!({ "outcomes": outcomes }))
883        }
884        "resolveCommitOutcome" => {
885            let input = serde_json::from_value::<ResolveCommitOutcomeInput>(
886                params.get("input").cloned().ok_or_else(|| {
887                    client_err(
888                        "sync.invalid_request: resolveCommitOutcome missing input".to_owned(),
889                    )
890                })?,
891            )
892            .map_err(|error| {
893                client_err(format!(
894                    "sync.invalid_request: invalid outcome resolution: {error}"
895                ))
896            })?;
897            let outcome = need_client(client)?
898                .resolve_commit_outcome(input)
899                .map_err(client_err)?;
900            Ok(json!({ "outcome": outcome }))
901        }
902        "pendingCommitIds" => {
903            let ids = need_client(client)?.pending_commit_ids();
904            Ok(json!({ "ids": ids }))
905        }
906        "pendingPayloads" => {
907            let payloads = need_client(client)?.pending_payloads();
908            Ok(json!({
909                "payloads": payloads.iter().map(|p| bytes_value(p)).collect::<Vec<_>>()
910            }))
911        }
912        "subscriptionState" => {
913            let id = params
914                .get("id")
915                .and_then(Value::as_str)
916                .ok_or_else(|| client_err("subscriptionState missing id".to_owned()))?;
917            let state = need_client(client)?.subscription_state(id);
918            Ok(json!({ "state": state }))
919        }
920        "recreateWithSchema" => {
921            // §7.4.2 "app ships new code": swap to the new schema on the SAME
922            // in-memory database (the Rust core has no persistent restart, so
923            // recreation IS the boot). Fires the §7.4.1 marker check.
924            let schema = params
925                .get("schema")
926                .ok_or_else(|| client_err("recreateWithSchema missing schema".to_owned()))?;
927            need_client(client)?
928                .recreate_with_schema(schema)
929                .map_err(client_err)?;
930            Ok(json!({}))
931        }
932        "connectRealtime" => {
933            need_client(client)?
934                .connect_realtime(transport)
935                .map_err(client_err)?;
936            Ok(json!({}))
937        }
938        "disconnectRealtime" => {
939            need_client(client)?.disconnect_realtime(transport);
940            Ok(json!({}))
941        }
942        "setPresence" => {
943            let scope_key = params
944                .get("scopeKey")
945                .and_then(Value::as_str)
946                .ok_or_else(|| client_err("setPresence missing scopeKey".to_owned()))?
947                .to_owned();
948            // §8.6.2: `doc` may be a JSON object or null (a leave).
949            let doc = params.get("doc");
950            let doc_ref = match doc {
951                None | Some(Value::Null) => None,
952                Some(v) => Some(v),
953            };
954            need_client(client)?
955                .set_presence(transport, &scope_key, doc_ref)
956                .map_err(client_err)?;
957            Ok(json!({}))
958        }
959        "presence" => {
960            let scope_key = params
961                .get("scopeKey")
962                .and_then(Value::as_str)
963                .ok_or_else(|| client_err("presence missing scopeKey".to_owned()))?;
964            let peers = need_client(client)?.presence(scope_key);
965            Ok(json!({ "peers": peers }))
966        }
967
968        // -- CodecDriver surface (Appendix A) — no client instance needed --
969        "messageRoundtrip" => {
970            let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
971            match decode_message(&bytes) {
972                Ok(message) => Ok(json!({
973                    "ok": true,
974                    "bytes": bytes_value(&encode_message(&message)),
975                    "renderedJson": render_message(&message).to_string(),
976                })),
977                Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
978            }
979        }
980        "segmentRoundtrip" => {
981            let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
982            match decode_rows_segment(&bytes) {
983                Ok(segment) => Ok(json!({
984                    "ok": true,
985                    "bytes": bytes_value(&encode_rows_segment(&segment)),
986                    "renderedJson": render_rows_segment(&segment).to_string(),
987                })),
988                Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
989            }
990        }
991        "realtimeKnown" => {
992            let text = params
993                .get("text")
994                .and_then(Value::as_str)
995                .ok_or_else(|| client_err("realtimeKnown missing text".to_owned()))?;
996            let known = matches!(
997                parse_control(text),
998                Ok(ControlMessage::Hello { .. })
999                    | Ok(ControlMessage::Wake { .. })
1000                    | Ok(ControlMessage::Heartbeat { .. })
1001                    | Ok(ControlMessage::Presence { .. })
1002            );
1003            Ok(json!({ "value": known }))
1004        }
1005
1006        other => Err(client_err(format!("unknown method {other:?}"))),
1007    }
1008}
1009
1010#[cfg(test)]
1011mod tests {
1012    use serde_json::{json, Value};
1013    use syncular_client::{
1014        SegmentRequest, SyncClient, Transport, TransportError, SECURITY_PREFLIGHT_REQUIRED_CODE,
1015    };
1016
1017    use super::{dispatch, parse_encryption, parse_headers, CreateEffects};
1018
1019    #[derive(Default)]
1020    struct NoNetwork {
1021        realtime_connects: usize,
1022        realtime_closes: usize,
1023    }
1024
1025    impl Transport for NoNetwork {
1026        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1027            Err(TransportError::new("sync.transport_failed", "offline"))
1028        }
1029
1030        fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1031            Err(TransportError::new("sync.transport_failed", "offline"))
1032        }
1033
1034        fn download_segment(
1035            &mut self,
1036            _request: &SegmentRequest,
1037            _on_progress: &mut dyn FnMut(u64),
1038        ) -> Result<Vec<u8>, TransportError> {
1039            Err(TransportError::new("sync.transport_failed", "offline"))
1040        }
1041
1042        fn realtime_connect(&mut self) -> Result<(), TransportError> {
1043            self.realtime_connects += 1;
1044            Ok(())
1045        }
1046
1047        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1048            Ok(())
1049        }
1050
1051        fn realtime_close(&mut self) -> Result<(), TransportError> {
1052            self.realtime_closes += 1;
1053            Ok(())
1054        }
1055    }
1056
1057    fn schema() -> Value {
1058        json!({
1059            "version": 1,
1060            "tables": [{
1061                "name": "todos",
1062                "columns": [
1063                    { "name": "id", "type": "string", "nullable": false },
1064                    { "name": "list_id", "type": "string", "nullable": false }
1065                ],
1066                "primaryKey": "id",
1067                "scopes": [{ "pattern": "list:{list_id}", "column": "list_id" }]
1068            }]
1069        })
1070    }
1071
1072    #[test]
1073    fn native_command_realtime_connection_is_idempotent() {
1074        let mut transport = NoNetwork::default();
1075        let mut client: Option<SyncClient> = None;
1076        let mut effects = CreateEffects::default();
1077        dispatch(
1078            &mut transport,
1079            &mut client,
1080            &mut effects,
1081            "create",
1082            &json!({ "schema": schema() }),
1083        )
1084        .expect("create client");
1085
1086        for _ in 0..2 {
1087            dispatch(
1088                &mut transport,
1089                &mut client,
1090                &mut effects,
1091                "connectRealtime",
1092                &json!({}),
1093            )
1094            .expect("idempotent connect command");
1095        }
1096        assert_eq!(transport.realtime_connects, 1);
1097
1098        dispatch(
1099            &mut transport,
1100            &mut client,
1101            &mut effects,
1102            "disconnectRealtime",
1103            &json!({}),
1104        )
1105        .expect("disconnect command");
1106        dispatch(
1107            &mut transport,
1108            &mut client,
1109            &mut effects,
1110            "disconnectRealtime",
1111            &json!({}),
1112        )
1113        .expect("idempotent disconnect command");
1114        assert_eq!(transport.realtime_closes, 1);
1115        dispatch(
1116            &mut transport,
1117            &mut client,
1118            &mut effects,
1119            "connectRealtime",
1120            &json!({}),
1121        )
1122        .expect("deliberate reconnect command");
1123        assert_eq!(transport.realtime_connects, 2);
1124    }
1125
1126    #[test]
1127    fn parses_portable_encryption_keyring_and_key_id_columns() {
1128        let key_hex = "2a".repeat(32);
1129        let config = parse_encryption(&json!({
1130            "keys": { "practice-key-v1": { "$bytes": key_hex } },
1131            "keyIdColumns": { "patients": "encryption_key_id" }
1132        }))
1133        .expect("portable keyring parses");
1134        assert_eq!(config.keys["practice-key-v1"], vec![0x2a; 32]);
1135        assert_eq!(config.key_id_columns["patients"], "encryption_key_id");
1136    }
1137
1138    #[test]
1139    fn rejects_non_string_key_id_columns() {
1140        let error = parse_encryption(&json!({
1141            "keys": {},
1142            "keyIdColumns": { "patients": 7 }
1143        }))
1144        .expect_err("invalid selector must fail");
1145        assert!(error.contains("must be a string"), "{error}");
1146    }
1147
1148    #[test]
1149    fn native_command_hosts_reject_subscription_identity_rebinds() {
1150        let mut transport = NoNetwork::default();
1151        let mut client: Option<SyncClient> = None;
1152        let mut effects = CreateEffects::default();
1153        dispatch(
1154            &mut transport,
1155            &mut client,
1156            &mut effects,
1157            "create",
1158            &json!({ "schema": schema() }),
1159        )
1160        .expect("create client");
1161
1162        let original = json!({
1163            "id": "stable-subscription",
1164            "table": "todos",
1165            "scopes": { "list_id": ["list-2", "list-1"] },
1166            "params": "{\"view\":\"v1\"}"
1167        });
1168        dispatch(
1169            &mut transport,
1170            &mut client,
1171            &mut effects,
1172            "subscribe",
1173            &original,
1174        )
1175        .expect("register subscription");
1176        dispatch(
1177            &mut transport,
1178            &mut client,
1179            &mut effects,
1180            "subscribe",
1181            &json!({
1182                "id": "stable-subscription",
1183                "table": "todos",
1184                "scopes": { "list_id": ["list-1", "list-2", "list-1"] },
1185                "params": "{\"view\":\"v1\"}"
1186            }),
1187        )
1188        .expect("canonical re-declaration is idempotent");
1189
1190        let error = dispatch(
1191            &mut transport,
1192            &mut client,
1193            &mut effects,
1194            "subscribe",
1195            &json!({
1196                "id": "stable-subscription",
1197                "table": "todos",
1198                "scopes": { "list_id": ["list-2"] },
1199                "params": "{\"view\":\"v1\"}"
1200            }),
1201        )
1202        .expect_err("changed native query identity must fail");
1203        assert_eq!(error.0, "client.subscription_intent_mismatch");
1204
1205        let state = dispatch(
1206            &mut transport,
1207            &mut client,
1208            &mut effects,
1209            "subscriptionState",
1210            &json!({ "id": "stable-subscription" }),
1211        )
1212        .expect("subscription state");
1213        assert_eq!(state["state"]["cursor"], -1);
1214        assert_eq!(state["state"]["table"], "todos");
1215    }
1216
1217    #[test]
1218    fn security_preflight_is_fail_closed_until_exact_activation() {
1219        let mut transport = NoNetwork::default();
1220        let mut client: Option<SyncClient> = None;
1221        let mut effects = CreateEffects::default();
1222        dispatch(
1223            &mut transport,
1224            &mut client,
1225            &mut effects,
1226            "create",
1227            &json!({ "schema": schema(), "securityPreflight": true }),
1228        )
1229        .expect("preflight create");
1230
1231        let lifecycle = dispatch(
1232            &mut transport,
1233            &mut client,
1234            &mut effects,
1235            "securityLifecycle",
1236            &json!({}),
1237        )
1238        .expect("lifecycle");
1239        assert_eq!(lifecycle, json!({ "state": "preflight" }));
1240
1241        let query_error = dispatch(
1242            &mut transport,
1243            &mut client,
1244            &mut effects,
1245            "query",
1246            &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1247        )
1248        .expect_err("protected query must fail");
1249        assert_eq!(query_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1250        let diagnostics_error = dispatch(
1251            &mut transport,
1252            &mut client,
1253            &mut effects,
1254            "diagnosticsSnapshot",
1255            &json!({}),
1256        )
1257        .expect_err("diagnostics table/subscription evidence remains protected");
1258        assert_eq!(diagnostics_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1259
1260        dispatch(
1261            &mut transport,
1262            &mut client,
1263            &mut effects,
1264            "purgeLocalData",
1265            &json!({
1266                "input": {
1267                    "purgeId": "directive-1",
1268                    "targets": [{
1269                        "table": "todos",
1270                        "selectors": { "list_id": ["list-1"] }
1271                    }]
1272                }
1273            }),
1274        )
1275        .expect("authorized local purge remains available");
1276
1277        let repair_error = dispatch(
1278            &mut transport,
1279            &mut client,
1280            &mut effects,
1281            "rebootstrapLocalData",
1282            &json!({ "input": { "rebootstrapId": "blocked-repair" } }),
1283        )
1284        .expect_err("projection repair must remain protected during preflight");
1285        assert_eq!(repair_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1286
1287        dispatch(
1288            &mut transport,
1289            &mut client,
1290            &mut effects,
1291            "activateSecurity",
1292            &json!({}),
1293        )
1294        .expect("activation");
1295        let rows = dispatch(
1296            &mut transport,
1297            &mut client,
1298            &mut effects,
1299            "query",
1300            &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1301        )
1302        .expect("active query");
1303        assert_eq!(rows, json!({ "rows": [] }));
1304
1305        dispatch(
1306            &mut transport,
1307            &mut client,
1308            &mut effects,
1309            "beginSecurityPreflight",
1310            &json!({}),
1311        )
1312        .expect("re-enter preflight");
1313        let blocked = dispatch(
1314            &mut transport,
1315            &mut client,
1316            &mut effects,
1317            "mutate",
1318            &json!({ "mutations": [] }),
1319        )
1320        .expect_err("mutation must be gated");
1321        assert_eq!(blocked.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1322    }
1323
1324    #[test]
1325    fn preflight_refuses_a_replacement_create_without_the_flag() {
1326        let mut transport = NoNetwork::default();
1327        let mut client: Option<SyncClient> = None;
1328        let mut effects = CreateEffects::default();
1329        dispatch(
1330            &mut transport,
1331            &mut client,
1332            &mut effects,
1333            "create",
1334            &json!({ "schema": schema(), "securityPreflight": true }),
1335        )
1336        .expect("preflight create");
1337
1338        // The escape the gate exists to prevent: a plain re-create must fail.
1339        let escape = dispatch(
1340            &mut transport,
1341            &mut client,
1342            &mut effects,
1343            "create",
1344            &json!({ "schema": schema() }),
1345        )
1346        .expect_err("plain create must be refused during preflight");
1347        assert_eq!(escape.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1348        // The refused create left the preflighted client installed and gated.
1349        let query_error = dispatch(
1350            &mut transport,
1351            &mut client,
1352            &mut effects,
1353            "query",
1354            &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1355        )
1356        .expect_err("protected query stays gated");
1357        assert_eq!(query_error.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1358
1359        // A preflighted replacement is permitted and stays gated.
1360        dispatch(
1361            &mut transport,
1362            &mut client,
1363            &mut effects,
1364            "create",
1365            &json!({ "schema": schema(), "securityPreflight": true }),
1366        )
1367        .expect("preflighted replacement create");
1368        let still_gated = dispatch(
1369            &mut transport,
1370            &mut client,
1371            &mut effects,
1372            "query",
1373            &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1374        )
1375        .expect_err("replacement stays gated");
1376        assert_eq!(still_gated.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1377
1378        // A legitimate activation releases the gate; creates behave as today.
1379        dispatch(
1380            &mut transport,
1381            &mut client,
1382            &mut effects,
1383            "activateSecurity",
1384            &json!({}),
1385        )
1386        .expect("activation");
1387        dispatch(
1388            &mut transport,
1389            &mut client,
1390            &mut effects,
1391            "create",
1392            &json!({ "schema": schema() }),
1393        )
1394        .expect("plain create after activation");
1395        let rows = dispatch(
1396            &mut transport,
1397            &mut client,
1398            &mut effects,
1399            "query",
1400            &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1401        )
1402        .expect("active query");
1403        assert_eq!(rows, json!({ "rows": [] }));
1404    }
1405
1406    /// A file-backed replica: the persisted quarantine marker only exists here,
1407    /// which is why the in-memory tests above could not catch this.
1408    fn temp_db_path(tag: &str) -> String {
1409        std::env::temp_dir()
1410            .join(format!(
1411                "syncular-command-{tag}-{}-{:?}.db",
1412                std::process::id(),
1413                std::time::SystemTime::now()
1414                    .duration_since(std::time::UNIX_EPOCH)
1415                    .expect("clock")
1416                    .as_nanos()
1417            ))
1418            .to_string_lossy()
1419            .into_owned()
1420    }
1421
1422    #[test]
1423    fn an_activated_replica_reopens_plainly_after_shutdown() {
1424        // Shutting an ACTIVATED client down is not a quarantine event. The
1425        // teardown barrier used to call `begin_security_preflight`, which now
1426        // persists the gate, so every cleanly closed replica was marked pending
1427        // and its next plain `create` was refused forever — the reopen path
1428        // restores the flag from the marker and the create guard rejects it.
1429        let path = temp_db_path("activated-reopen");
1430        let mut transport = NoNetwork::default();
1431        let mut client: Option<SyncClient> = None;
1432        let mut effects = CreateEffects::default();
1433
1434        dispatch(
1435            &mut transport,
1436            &mut client,
1437            &mut effects,
1438            "create",
1439            &json!({ "schema": schema(), "securityPreflight": true, "dbPath": path }),
1440        )
1441        .expect("preflight create");
1442        dispatch(
1443            &mut transport,
1444            &mut client,
1445            &mut effects,
1446            "activateSecurity",
1447            &json!({}),
1448        )
1449        .expect("activation clears the gate");
1450        dispatch(
1451            &mut transport,
1452            &mut client,
1453            &mut effects,
1454            "shutdown",
1455            &json!({}),
1456        )
1457        .expect("shutdown after activation");
1458
1459        // The replica is active on disk, so a plain re-create must be admitted.
1460        dispatch(
1461            &mut transport,
1462            &mut client,
1463            &mut effects,
1464            "create",
1465            &json!({ "schema": schema(), "dbPath": path }),
1466        )
1467        .expect("an activated replica must reopen plainly after shutdown");
1468        let state = dispatch(
1469            &mut transport,
1470            &mut client,
1471            &mut effects,
1472            "securityLifecycle",
1473            &json!({}),
1474        )
1475        .expect("lifecycle");
1476        assert_eq!(state, json!({ "state": "active" }));
1477
1478        let _ = std::fs::remove_file(&path);
1479    }
1480
1481    #[test]
1482    fn an_unactivated_replica_stays_quarantined_across_shutdown_on_disk() {
1483        // The other half, and the reason the marker exists: a replica that
1484        // entered preflight and never activated must still refuse a plain
1485        // re-create after shutdown, even on a host that rebuilt its handle and
1486        // carries no in-memory flag.
1487        let path = temp_db_path("quarantined-reopen");
1488        let mut transport = NoNetwork::default();
1489        let mut client: Option<SyncClient> = None;
1490        let mut effects = CreateEffects::default();
1491
1492        dispatch(
1493            &mut transport,
1494            &mut client,
1495            &mut effects,
1496            "create",
1497            &json!({ "schema": schema(), "securityPreflight": true, "dbPath": path }),
1498        )
1499        .expect("preflight create");
1500        dispatch(
1501            &mut transport,
1502            &mut client,
1503            &mut effects,
1504            "shutdown",
1505            &json!({}),
1506        )
1507        .expect("shutdown during preflight");
1508
1509        // A fresh host handle: no in-memory pending flag survives here, so only
1510        // the persisted marker can hold the gate.
1511        let mut rebuilt = CreateEffects::default();
1512        let escape = dispatch(
1513            &mut transport,
1514            &mut client,
1515            &mut rebuilt,
1516            "create",
1517            &json!({ "schema": schema(), "dbPath": path }),
1518        )
1519        .expect_err("a quarantined replica must refuse a plain re-create");
1520        assert_eq!(escape.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1521
1522        let _ = std::fs::remove_file(&path);
1523    }
1524
1525    #[test]
1526    fn preflight_gate_survives_shutdown_before_replacement_creates() {
1527        let mut transport = NoNetwork::default();
1528        let mut client: Option<SyncClient> = None;
1529        let mut effects = CreateEffects::default();
1530        dispatch(
1531            &mut transport,
1532            &mut client,
1533            &mut effects,
1534            "create",
1535            &json!({ "schema": schema(), "securityPreflight": true }),
1536        )
1537        .expect("preflight create");
1538        dispatch(
1539            &mut transport,
1540            &mut client,
1541            &mut effects,
1542            "shutdown",
1543            &json!({}),
1544        )
1545        .expect("shutdown during preflight");
1546        assert!(client.is_none());
1547
1548        // The pending preflight rides `effects` across the empty client slot.
1549        let escape = dispatch(
1550            &mut transport,
1551            &mut client,
1552            &mut effects,
1553            "create",
1554            &json!({ "schema": schema() }),
1555        )
1556        .expect_err("shutdown + plain create must stay refused");
1557        assert_eq!(escape.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1558
1559        dispatch(
1560            &mut transport,
1561            &mut client,
1562            &mut effects,
1563            "create",
1564            &json!({ "schema": schema(), "securityPreflight": true }),
1565        )
1566        .expect("preflighted re-create");
1567        dispatch(
1568            &mut transport,
1569            &mut client,
1570            &mut effects,
1571            "activateSecurity",
1572            &json!({}),
1573        )
1574        .expect("activation");
1575
1576        // An ACTIVATED client shut down cleanly may be recreated plainly.
1577        dispatch(
1578            &mut transport,
1579            &mut client,
1580            &mut effects,
1581            "shutdown",
1582            &json!({}),
1583        )
1584        .expect("shutdown after activation");
1585        dispatch(
1586            &mut transport,
1587            &mut client,
1588            &mut effects,
1589            "create",
1590            &json!({ "schema": schema() }),
1591        )
1592        .expect("plain create after an activated shutdown");
1593    }
1594
1595    #[test]
1596    fn activate_security_validates_optional_headers_atomically() {
1597        let mut transport = NoNetwork::default();
1598        let mut client: Option<SyncClient> = None;
1599        let mut effects = CreateEffects::default();
1600        dispatch(
1601            &mut transport,
1602            &mut client,
1603            &mut effects,
1604            "create",
1605            &json!({ "schema": schema(), "securityPreflight": true }),
1606        )
1607        .expect("preflight create");
1608
1609        // Invalid header shapes fail loudly and keep the gate closed.
1610        let invalid = dispatch(
1611            &mut transport,
1612            &mut client,
1613            &mut effects,
1614            "activateSecurity",
1615            &json!({ "headers": { "authorization": 7 } }),
1616        )
1617        .expect_err("non-string header must fail");
1618        assert_eq!(invalid.0, "sync.invalid_request");
1619        let lifecycle = dispatch(
1620            &mut transport,
1621            &mut client,
1622            &mut effects,
1623            "securityLifecycle",
1624            &json!({}),
1625        )
1626        .expect("lifecycle");
1627        assert_eq!(lifecycle, json!({ "state": "preflight" }));
1628
1629        // A valid header set activates in one atomic step.
1630        dispatch(
1631            &mut transport,
1632            &mut client,
1633            &mut effects,
1634            "activateSecurity",
1635            &json!({ "headers": { "authorization": "Bearer fresh" } }),
1636        )
1637        .expect("activation with fresh headers");
1638        let lifecycle = dispatch(
1639            &mut transport,
1640            &mut client,
1641            &mut effects,
1642            "securityLifecycle",
1643            &json!({}),
1644        )
1645        .expect("lifecycle");
1646        assert_eq!(lifecycle, json!({ "state": "active" }));
1647    }
1648
1649    #[test]
1650    fn parse_headers_reads_the_full_replacement_set() {
1651        let parsed = parse_headers(&json!({
1652            "authorization": "Bearer fresh",
1653            "x-tenant": "t1"
1654        }))
1655        .expect("valid headers parse");
1656        assert_eq!(
1657            parsed,
1658            vec![
1659                ("authorization".to_owned(), "Bearer fresh".to_owned()),
1660                ("x-tenant".to_owned(), "t1".to_owned())
1661            ]
1662        );
1663        assert!(parse_headers(&json!(["authorization"])).is_err());
1664        assert!(parse_headers(&json!({ "authorization": null })).is_err());
1665    }
1666
1667    #[test]
1668    fn set_headers_validates_replacement_and_respects_preflight() {
1669        let mut transport = NoNetwork::default();
1670        let mut client: Option<SyncClient> = None;
1671        let mut effects = CreateEffects::default();
1672        dispatch(
1673            &mut transport,
1674            &mut client,
1675            &mut effects,
1676            "create",
1677            &json!({ "schema": schema() }),
1678        )
1679        .expect("create");
1680        dispatch(
1681            &mut transport,
1682            &mut client,
1683            &mut effects,
1684            "setHeaders",
1685            &json!({ "headers": { "authorization": "Bearer fresh" } }),
1686        )
1687        .expect("valid replacement");
1688        let invalid = dispatch(
1689            &mut transport,
1690            &mut client,
1691            &mut effects,
1692            "setHeaders",
1693            &json!({ "headers": { "authorization": 7 } }),
1694        )
1695        .expect_err("invalid replacement");
1696        assert_eq!(invalid.0, "sync.invalid_request");
1697
1698        dispatch(
1699            &mut transport,
1700            &mut client,
1701            &mut effects,
1702            "beginSecurityPreflight",
1703            &json!({}),
1704        )
1705        .expect("preflight");
1706        let gated = dispatch(
1707            &mut transport,
1708            &mut client,
1709            &mut effects,
1710            "setHeaders",
1711            &json!({ "headers": {} }),
1712        )
1713        .expect_err("direct rotation must respect preflight");
1714        assert_eq!(gated.0, SECURITY_PREFLIGHT_REQUIRED_CODE);
1715    }
1716}