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