Skip to main content

lodestone_client/
transport.rs

1use std::collections::VecDeque;
2use std::error::Error;
3use std::fmt::{self, Display, Formatter};
4use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
5use std::sync::{Arc, Mutex};
6use std::time::{Duration, SystemTime, UNIX_EPOCH};
7
8use lodestone_wire::{
9    Action, Command, CoreBinding, DiscoverRequest, DiscoverResult, ErrorBody, GetBindingRequest, GetBindingResult,
10    Identity, LODESTONE_API_ALPN, LODESTONE_API_COMMAND_CONTRACTS, LODESTONE_API_COMMAND_OUTCOMES,
11    LODESTONE_API_COMMAND_SEMANTICS, LODESTONE_API_ERROR_OUTCOMES, LODESTONE_API_FORMAT_VERSION,
12    LODESTONE_API_SCHEMA_ID, LODESTONE_API_SPEC_SHA256, LODESTONE_API_STREAM_CONTRACTS, RawJson, RequestBody,
13    RequestEnvelope, RequestMeta, ResponseEnvelope, ResultBody, U64, WorkloadClass, command_for_result,
14    validate_error_body,
15};
16use rustls::pki_types::{CertificateDer, ServerName};
17use tokio::io::{AsyncReadExt, AsyncWriteExt};
18use tokio::net::TcpStream;
19use tokio::sync::{Mutex as AsyncMutex, Notify, Semaphore};
20use tokio_rustls::TlsConnector;
21use tokio_rustls::client::TlsStream;
22
23use crate::config::{
24    CellTransportConfig, CellTransportIdentity, ClientConfigError, CollectionSessionConfig, PoolConfig, parse_identity,
25};
26use crate::credential::{CredentialMaterial, CredentialProviderError};
27use crate::generated::{
28    GeneratedCollectionIndexRef, MAX_NATIVE_REQUEST_BYTES, MAX_NATIVE_RESPONSE_BYTES, request_collection_index_ref,
29    request_command,
30};
31
32const PUBLIC_SESSION_FORMAT_VERSION: u16 = 3;
33const MAX_PUBLIC_SESSION_BYTES: usize = 1_024;
34const MAX_REQUEST_ID_BYTES: usize = 128;
35const MAX_CALL_TIMEOUT: Duration = Duration::from_secs(10 * 60);
36const DEFAULT_CALL_TIMEOUT: Duration = Duration::from_secs(30);
37pub const MAX_VERIFIED_SESSION_METADATA: usize = 4_096;
38
39#[derive(Clone)]
40pub struct CallCancellation {
41    inner: Arc<CallCancellationInner>,
42}
43
44struct CallCancellationInner {
45    cancelled: AtomicBool,
46    notify: Notify,
47}
48
49impl CallCancellation {
50    #[must_use]
51    pub fn new() -> Self {
52        Self {
53            inner: Arc::new(CallCancellationInner {
54                cancelled: AtomicBool::new(false),
55                notify: Notify::new(),
56            }),
57        }
58    }
59
60    pub fn cancel(&self) {
61        if !self.inner.cancelled.swap(true, Ordering::AcqRel) {
62            self.inner.notify.notify_waiters();
63        }
64    }
65
66    #[must_use]
67    pub fn is_cancelled(&self) -> bool {
68        self.inner.cancelled.load(Ordering::Acquire)
69    }
70
71    /// Waits until this shared call scope is cancelled.
72    pub async fn cancelled(&self) {
73        loop {
74            let notified = self.inner.notify.notified();
75            if self.is_cancelled() {
76                return;
77            }
78            notified.await;
79        }
80    }
81}
82
83impl Default for CallCancellation {
84    fn default() -> Self {
85        Self::new()
86    }
87}
88
89#[derive(Clone)]
90pub struct CallOptions {
91    deadline: CallDeadline,
92    cancellation: CallCancellation,
93    index_name: Option<String>,
94    workload_class: Option<WorkloadClass>,
95}
96
97#[derive(Clone, Copy)]
98struct CallDeadline {
99    absolute_unix_millis: Option<u64>,
100    local: tokio::time::Instant,
101}
102
103impl CallOptions {
104    /// Creates a call scope whose absolute deadline is anchored now.
105    ///
106    /// Cloning these options preserves that original deadline; it never turns
107    /// the supplied duration into a new per-request timeout.
108    pub fn new(timeout: Duration) -> Result<Self, ClientError> {
109        Ok(Self {
110            deadline: CallDeadline::after(timeout)?,
111            cancellation: CallCancellation::new(),
112            index_name: None,
113            workload_class: None,
114        })
115    }
116
117    /// Creates a call scope from an existing absolute Unix-millisecond
118    /// deadline. The deadline must be in the future and no more than the
119    /// client maximum call budget away.
120    pub fn from_absolute_deadline_unix_millis(absolute_unix_millis: u64) -> Result<Self, ClientError> {
121        Ok(Self {
122            deadline: CallDeadline::from_absolute_unix_millis(absolute_unix_millis)?,
123            cancellation: CallCancellation::new(),
124            index_name: None,
125            workload_class: None,
126        })
127    }
128
129    #[must_use]
130    pub fn with_cancellation(mut self, cancellation: CallCancellation) -> Self {
131        self.cancellation = cancellation;
132        self
133    }
134
135    pub fn with_index_name(mut self, index_name: String) -> Result<Self, ClientError> {
136        validate_index_name(&index_name)?;
137        self.index_name = Some(index_name);
138        Ok(self)
139    }
140
141    /// Selects a workload class for this call. The server still requires the
142    /// authenticated role to grant the class and checks action compatibility.
143    #[must_use]
144    pub fn with_workload_class(mut self, workload_class: WorkloadClass) -> Self {
145        self.workload_class = Some(workload_class);
146        self
147    }
148
149    #[must_use]
150    pub const fn cancellation(&self) -> &CallCancellation {
151        &self.cancellation
152    }
153
154    /// Returns the exact deadline placed in every request envelope in this
155    /// call scope.
156    pub fn absolute_deadline_unix_millis(&self) -> Result<u64, ClientError> {
157        self.deadline.absolute_unix_millis.ok_or(ClientError::ClockUnavailable)
158    }
159
160    /// Returns the budget left in this call scope without resetting its
161    /// absolute deadline.
162    pub fn remaining(&self) -> Result<Duration, ClientError> {
163        self.local_deadline()?
164            .checked_duration_since(tokio::time::Instant::now())
165            .ok_or(ClientError::Transport {
166                kind: TransportErrorKind::Deadline,
167                disposition: SubmissionDisposition::NotSubmitted,
168            })
169    }
170
171    pub(crate) fn local_deadline(&self) -> Result<tokio::time::Instant, ClientError> {
172        self.absolute_deadline_unix_millis().map(|_| self.deadline.local)
173    }
174
175    pub(crate) fn for_resolution(&self) -> Self {
176        Self {
177            deadline: self.deadline,
178            cancellation: CallCancellation::new(),
179            index_name: self.index_name.clone(),
180            workload_class: self.workload_class,
181        }
182    }
183}
184
185impl CallDeadline {
186    fn after(timeout: Duration) -> Result<Self, ClientError> {
187        let timeout = normalized_timeout(timeout)?;
188        let local_now = tokio::time::Instant::now();
189        let wall_deadline = SystemTime::now()
190            .checked_add(timeout)
191            .ok_or(ClientError::ClockUnavailable)?;
192        let since_epoch = wall_deadline
193            .duration_since(UNIX_EPOCH)
194            .map_err(|_| ClientError::ClockUnavailable)?;
195        Ok(Self {
196            absolute_unix_millis: Some(duration_millis_ceil(since_epoch)?),
197            local: local_now + timeout,
198        })
199    }
200
201    fn from_absolute_unix_millis(absolute_unix_millis: u64) -> Result<Self, ClientError> {
202        let local_now = tokio::time::Instant::now();
203        let wall_deadline = UNIX_EPOCH
204            .checked_add(Duration::from_millis(absolute_unix_millis))
205            .ok_or(ClientError::InvalidCallOptions)?;
206        let remaining = wall_deadline
207            .duration_since(SystemTime::now())
208            .map_err(|_| ClientError::InvalidCallOptions)?;
209        if remaining.is_zero() || remaining > MAX_CALL_TIMEOUT {
210            return Err(ClientError::InvalidCallOptions);
211        }
212        Ok(Self {
213            absolute_unix_millis: Some(absolute_unix_millis),
214            local: local_now + remaining,
215        })
216    }
217}
218
219impl Default for CallOptions {
220    fn default() -> Self {
221        match Self::new(DEFAULT_CALL_TIMEOUT) {
222            Ok(options) => options,
223            Err(_) => Self {
224                deadline: CallDeadline {
225                    absolute_unix_millis: None,
226                    local: tokio::time::Instant::now(),
227                },
228                cancellation: CallCancellation::new(),
229                index_name: None,
230                workload_class: None,
231            },
232        }
233    }
234}
235
236#[derive(Clone, Debug, Eq, PartialEq)]
237pub struct ProfileVerification {
238    pub artifact_version: String,
239    pub principal_id: Identity,
240    pub collection_id: Identity,
241    pub policy_revision: U64,
242    pub cluster_id: Identity,
243    pub configuration_generation: U64,
244    pub binding_revision: U64,
245    pub schema_id: String,
246    pub schema_digest: lodestone_wire::Digest,
247    pub native_endpoints: Vec<String>,
248    pub selected_stream_endpoint: String,
249}
250
251#[derive(Clone, Copy, Debug, Eq, PartialEq)]
252pub struct CellTransportMetricsSnapshot {
253    pub transport_count: usize,
254    pub live_connections: usize,
255    pub idle_connections: usize,
256    pub live_collection_sessions: usize,
257    pub session_cache_entries: usize,
258    pub session_cache_hits: u64,
259    pub session_cache_misses: u64,
260    pub session_cache_evictions: u64,
261    pub credential_fetches: u64,
262    pub bulkhead_saturation: u64,
263    pub isolation_failures: u64,
264}
265
266#[derive(Default)]
267struct CellTransportMetrics {
268    live_connections: AtomicUsize,
269    idle_connections: AtomicUsize,
270    live_collection_sessions: AtomicUsize,
271    session_cache_entries: AtomicUsize,
272    session_cache_hits: AtomicU64,
273    session_cache_misses: AtomicU64,
274    session_cache_evictions: AtomicU64,
275    credential_fetches: AtomicU64,
276    bulkhead_saturation: AtomicU64,
277    isolation_failures: AtomicU64,
278}
279
280impl CellTransportMetrics {
281    fn snapshot(&self) -> CellTransportMetricsSnapshot {
282        CellTransportMetricsSnapshot {
283            transport_count: 1,
284            live_connections: self.live_connections.load(Ordering::Relaxed),
285            idle_connections: self.idle_connections.load(Ordering::Relaxed),
286            live_collection_sessions: self.live_collection_sessions.load(Ordering::Relaxed),
287            session_cache_entries: self.session_cache_entries.load(Ordering::Relaxed),
288            session_cache_hits: self.session_cache_hits.load(Ordering::Relaxed),
289            session_cache_misses: self.session_cache_misses.load(Ordering::Relaxed),
290            session_cache_evictions: self.session_cache_evictions.load(Ordering::Relaxed),
291            credential_fetches: self.credential_fetches.load(Ordering::Relaxed),
292            bulkhead_saturation: self.bulkhead_saturation.load(Ordering::Relaxed),
293            isolation_failures: self.isolation_failures.load(Ordering::Relaxed),
294        }
295    }
296}
297
298#[derive(Clone, Debug, Eq, PartialEq)]
299struct VerifiedSessionCacheKey {
300    collection_id: String,
301    principal_id: String,
302    profile_generation: String,
303    credential_id: String,
304    credential_generation: String,
305    secret_reference: String,
306}
307
308impl VerifiedSessionCacheKey {
309    fn from_config(config: &CollectionSessionConfig) -> Self {
310        let profile = config.profile.as_wire();
311        Self {
312            collection_id: profile.expected_collection_id.value.clone(),
313            principal_id: profile.expected_principal_id.value.clone(),
314            profile_generation: profile.profile_generation.value.clone(),
315            credential_id: profile.credential_id.value.clone(),
316            credential_generation: profile.credential_generation.value.clone(),
317            secret_reference: profile.secret_reference.clone(),
318        }
319    }
320}
321
322struct VerifiedSessionMetadata {
323    key: VerifiedSessionCacheKey,
324    verification: ProfileVerification,
325}
326
327#[derive(Default)]
328struct VerifiedSessionCache {
329    entries: VecDeque<VerifiedSessionMetadata>,
330}
331
332impl VerifiedSessionCache {
333    fn get(&mut self, key: &VerifiedSessionCacheKey, metrics: &CellTransportMetrics) -> Option<ProfileVerification> {
334        let Some(position) = self.entries.iter().position(|entry| &entry.key == key) else {
335            metrics.session_cache_misses.fetch_add(1, Ordering::Relaxed);
336            return None;
337        };
338        let entry = self.entries.remove(position)?;
339        let verification = entry.verification.clone();
340        self.entries.push_back(entry);
341        metrics.session_cache_hits.fetch_add(1, Ordering::Relaxed);
342        Some(verification)
343    }
344
345    fn insert(
346        &mut self,
347        key: VerifiedSessionCacheKey,
348        verification: ProfileVerification,
349        metrics: &CellTransportMetrics,
350    ) {
351        if let Some(position) = self.entries.iter().position(|entry| entry.key == key) {
352            let _ = self.entries.remove(position);
353        } else if self.entries.len() == MAX_VERIFIED_SESSION_METADATA {
354            let _ = self.entries.pop_front();
355            metrics.session_cache_evictions.fetch_add(1, Ordering::Relaxed);
356        }
357        self.entries.push_back(VerifiedSessionMetadata { key, verification });
358        metrics
359            .session_cache_entries
360            .store(self.entries.len(), Ordering::Relaxed);
361    }
362}
363
364#[derive(Clone)]
365pub struct CellTransport {
366    inner: Arc<CellTransportInner>,
367}
368
369struct CellTransportInner {
370    config: CellTransportConfig,
371    tls: Arc<rustls::ClientConfig>,
372    pool: AsyncMutex<Vec<NativeConnection>>,
373    connection_slots: Arc<Semaphore>,
374    next_endpoint: AtomicUsize,
375    verified_sessions: Mutex<VerifiedSessionCache>,
376    metrics: Arc<CellTransportMetrics>,
377}
378
379#[derive(Clone)]
380pub struct Client {
381    inner: Arc<ClientInner>,
382}
383
384pub type CollectionSession = Client;
385
386struct ClientInner {
387    transport: CellTransport,
388    config: CollectionSessionConfig,
389    verified: Mutex<Option<ProfileVerification>>,
390}
391
392impl Drop for ClientInner {
393    fn drop(&mut self) {
394        self.transport
395            .inner
396            .metrics
397            .live_collection_sessions
398            .fetch_sub(1, Ordering::Relaxed);
399    }
400}
401
402struct NativeConnection {
403    stream: TlsStream<TcpStream>,
404    metrics: Arc<CellTransportMetrics>,
405    idle_since: tokio::time::Instant,
406}
407
408impl Drop for NativeConnection {
409    fn drop(&mut self) {
410        self.metrics.live_connections.fetch_sub(1, Ordering::Relaxed);
411    }
412}
413
414impl CellTransport {
415    pub fn new(config: CellTransportConfig) -> Result<Self, ClientError> {
416        let tls = build_tls_config(&config)?;
417        let metrics = Arc::new(CellTransportMetrics::default());
418        Ok(Self {
419            inner: Arc::new(CellTransportInner {
420                connection_slots: Arc::new(Semaphore::new(config.pool.maximum_connections)),
421                config,
422                tls: Arc::new(tls),
423                pool: AsyncMutex::new(Vec::new()),
424                next_endpoint: AtomicUsize::new(0),
425                verified_sessions: Mutex::new(VerifiedSessionCache::default()),
426                metrics,
427            }),
428        })
429    }
430
431    #[must_use]
432    pub fn identity(&self) -> &CellTransportIdentity {
433        self.inner.config.identity()
434    }
435
436    #[must_use]
437    pub fn pool_config(&self) -> PoolConfig {
438        self.inner.config.pool
439    }
440
441    #[must_use]
442    pub fn metrics(&self) -> CellTransportMetricsSnapshot {
443        self.inner.metrics.snapshot()
444    }
445}
446
447impl Client {
448    /// Connects, authenticates, discovers capabilities, and verifies the exact
449    /// cluster, collection, schema, stream, and binding identities in the profile.
450    pub async fn connect(
451        transport: CellTransport,
452        config: CollectionSessionConfig,
453        options: CallOptions,
454    ) -> Result<Self, ClientError> {
455        let client = Self::new(transport, config)?;
456        client.verify_connection_profile(options).await?;
457        Ok(client)
458    }
459
460    pub fn new(transport: CellTransport, config: CollectionSessionConfig) -> Result<Self, ClientError> {
461        if let Err(error) = config.validate_transport(&transport.inner.config) {
462            transport
463                .inner
464                .metrics
465                .isolation_failures
466                .fetch_add(1, Ordering::Relaxed);
467            return Err(error.into());
468        }
469        let cache_key = VerifiedSessionCacheKey::from_config(&config);
470        let cached = transport
471            .inner
472            .verified_sessions
473            .lock()
474            .map_err(|_| ClientError::InternalStateUnavailable)?
475            .get(&cache_key, &transport.inner.metrics);
476        if let Some(verification) = &cached
477            && validate_cached_verification(&config, verification).is_err()
478        {
479            transport
480                .inner
481                .metrics
482                .isolation_failures
483                .fetch_add(1, Ordering::Relaxed);
484            return Err(ClientError::ProfileIdentityMismatch);
485        }
486        transport
487            .inner
488            .metrics
489            .live_collection_sessions
490            .fetch_add(1, Ordering::Relaxed);
491        Ok(Self {
492            inner: Arc::new(ClientInner {
493                transport,
494                config,
495                // INVARIANT: shared metadata never authorizes a fresh session.
496                // Every session performs its own authenticated discover/binding
497                // exchange before mutation, even after an exact cache hit.
498                verified: Mutex::new(None),
499            }),
500        })
501    }
502
503    #[must_use]
504    pub fn connection_profile(&self) -> &crate::ValidatedConnectionProfile {
505        &self.inner.config.profile
506    }
507
508    /// Returns a handle to the exact shared cell transport for creating
509    /// additional isolated Collection sessions.
510    #[must_use]
511    pub fn cell_transport(&self) -> CellTransport {
512        self.inner.transport.clone()
513    }
514
515    #[must_use]
516    pub fn profile_verification(&self) -> Option<ProfileVerification> {
517        self.inner.verified.lock().ok().and_then(|verified| verified.clone())
518    }
519
520    pub async fn verify_connection_profile(&self, options: CallOptions) -> Result<ProfileVerification, ClientError> {
521        let discovered = self.discover(DiscoverRequest {}, options.clone()).await?;
522        self.validate_discovery_observation(&discovered)?;
523        let binding = self.get_binding(GetBindingRequest {}, options).await?;
524        self.verify_authority_binding_observation(&discovered, &binding)
525    }
526
527    /// Validates one authenticated discovery observation against the selected
528    /// profile without performing another network call.
529    pub fn validate_discovery_observation(&self, discovered: &DiscoverResult) -> Result<(), ClientError> {
530        validate_discovery(&self.inner.config, discovered)
531    }
532
533    /// Validates and installs one authenticated discovery/binding cut without
534    /// performing another network call.
535    pub fn verify_authority_binding_observation(
536        &self,
537        discovered: &DiscoverResult,
538        binding: &GetBindingResult,
539    ) -> Result<ProfileVerification, ClientError> {
540        validate_discovery(&self.inner.config, discovered)?;
541        validate_binding(&self.inner.config, &binding.binding, binding.derived, &binding.revision)?;
542        let verified = ProfileVerification {
543            artifact_version: discovered.artifact_version.clone(),
544            principal_id: discovered.principal_id.clone(),
545            collection_id: discovered.collection_id.clone(),
546            policy_revision: discovered.policy_revision.clone(),
547            cluster_id: binding.binding.cluster_id.clone(),
548            configuration_generation: binding.binding.configuration_generation.clone(),
549            binding_revision: binding.revision.clone(),
550            schema_id: binding.binding.schema_id.clone(),
551            schema_digest: binding.binding.schema_digest.clone(),
552            native_endpoints: binding.binding.native_endpoints.clone(),
553            selected_stream_endpoint: binding.binding.selected_stream_endpoint.clone(),
554        };
555        let mut slot = self
556            .inner
557            .verified
558            .lock()
559            .map_err(|_| ClientError::InternalStateUnavailable)?;
560        *slot = Some(verified.clone());
561        self.inner
562            .transport
563            .inner
564            .verified_sessions
565            .lock()
566            .map_err(|_| ClientError::InternalStateUnavailable)?
567            .insert(
568                VerifiedSessionCacheKey::from_config(&self.inner.config),
569                verified.clone(),
570                &self.inner.transport.inner.metrics,
571            );
572        Ok(verified)
573    }
574
575    /// Dispatches one closed Lodestone API request body through the same validated
576    /// transport and recovery boundary used by every generated typed method.
577    ///
578    /// The caller supplies no envelope, collection, credential, or arbitrary
579    /// header. Those remain owned by the validated connection profile,
580    /// credential provider, and call options.
581    pub async fn call(&self, request: RequestBody, options: CallOptions) -> Result<ResultBody, ClientError> {
582        let local_deadline = options.local_deadline()?;
583        if tokio::time::Instant::now() >= local_deadline {
584            return Err(ClientError::Transport {
585                kind: TransportErrorKind::Deadline,
586                disposition: SubmissionDisposition::NotSubmitted,
587            });
588        }
589        let command = request_command(&request);
590        let command_contract = LODESTONE_API_COMMAND_CONTRACTS
591            .iter()
592            .find(|contract| contract.command == command)
593            .ok_or(ClientError::MissingCommandContract)?;
594        let outcome_contract = LODESTONE_API_COMMAND_OUTCOMES
595            .iter()
596            .find(|contract| contract.command == command)
597            .ok_or(ClientError::MissingCommandContract)?;
598        let mutation = outcome_contract.possible_submission_states.contains(&"submitted");
599        if mutation && self.profile_verification().is_none() {
600            return Err(ClientError::ProfileNotVerified);
601        }
602        let index_name = resolve_index_name(&request, options.index_name.as_deref())?;
603        let correlation = random_identity()?;
604        let deadline = options.absolute_deadline_unix_millis()?;
605        let request_id = format!("r-{}", lower_hex(&correlation));
606        let workload_class = options
607            .workload_class
608            .unwrap_or_else(|| default_workload_class(command_contract.action));
609        let envelope = RequestEnvelope {
610            meta: RequestMeta {
611                format_version: LODESTONE_API_FORMAT_VERSION,
612                request_id: request_id.clone(),
613                correlation_id: Identity {
614                    value: lower_hex(&correlation),
615                },
616                absolute_deadline_unix_millis: U64 {
617                    value: deadline.to_string(),
618                },
619                workload_class,
620            },
621            request,
622        };
623        let payload = canonical_request(&envelope)?;
624        let started = Arc::new(AtomicBool::new(false));
625        let maximum_attempts = usize::from(self.pool_config().maximum_read_retries).saturating_add(1);
626        let mut attempt = 0_usize;
627        loop {
628            attempt = attempt.saturating_add(1);
629            let result = tokio::select! {
630                result = tokio::time::timeout_at(
631                    local_deadline,
632                    self.transport_attempt(
633                        &payload,
634                        &request_id,
635                        command,
636                        command_contract.action,
637                        workload_class,
638                        index_name.as_deref(),
639                        correlation,
640                        Arc::clone(&started),
641                    ),
642                ) => match result {
643                    Ok(result) => result,
644                    Err(_) => Err(ClientError::Transport {
645                        kind: TransportErrorKind::Deadline,
646                        disposition: started_disposition(&started),
647                    }),
648                },
649                () = options.cancellation.cancelled() => Err(ClientError::Transport {
650                    kind: TransportErrorKind::Cancelled,
651                    disposition: started_disposition(&started),
652                }),
653            };
654            match result {
655                Err(ClientError::Transport { disposition, .. })
656                    if attempt < maximum_attempts
657                        && !options.cancellation.is_cancelled()
658                        && retry_admitted(mutation, outcome_contract.pre_submit_retry_safe, disposition) =>
659                {
660                    continue;
661                }
662                result => return result,
663            }
664        }
665    }
666
667    #[allow(clippy::too_many_arguments)]
668    async fn transport_attempt(
669        &self,
670        payload: &[u8],
671        request_id: &str,
672        command: Command,
673        action: Action,
674        workload_class: WorkloadClass,
675        index_name: Option<&str>,
676        correlation: [u8; 16],
677        started: Arc<AtomicBool>,
678    ) -> Result<ResultBody, ClientError> {
679        // INVARIANT: Collection-local credential failures cannot consume or
680        // discard a shared cell connection. Material remains request-local and
681        // is fully encoded before this call enters the transport bulkhead.
682        let credential = self.credential().await?;
683        let native_credential = native_credential(&credential)?;
684        let session = PublicSession {
685            format_version: PUBLIC_SESSION_FORMAT_VERSION,
686            api_key_credential: native_credential.as_slice(),
687            collection_id: &self.inner.config.profile.as_wire().expected_collection_id.value,
688            request_id,
689            action: action_name(action),
690            workload_class,
691            index_name,
692            request_correlation_id: correlation,
693        };
694        let session = SensitiveBuffer(serde_json::to_vec(&session).map_err(|_| ClientError::RequestCodec)?);
695        if session.is_empty() || session.len() > MAX_PUBLIC_SESSION_BYTES {
696            return Err(ClientError::RequestTooLarge);
697        }
698        let mut wire = SensitiveBuffer::new();
699        wire.extend_u32(session.len())?;
700        wire.extend(session.as_slice());
701        wire.extend_u32(payload.len())?;
702        wire.extend(payload);
703
704        if self.inner.transport.inner.connection_slots.available_permits() == 0 {
705            self.inner
706                .transport
707                .inner
708                .metrics
709                .bulkhead_saturation
710                .fetch_add(1, Ordering::Relaxed);
711        }
712        let _permit = self
713            .inner
714            .transport
715            .inner
716            .connection_slots
717            .clone()
718            .acquire_owned()
719            .await
720            .map_err(|_| ClientError::Transport {
721                kind: TransportErrorKind::PoolClosed,
722                disposition: SubmissionDisposition::NotSubmitted,
723            })?;
724        let endpoint_index = self.inner.transport.inner.next_endpoint.fetch_add(1, Ordering::Relaxed)
725            % self.inner.transport.inner.config.native_endpoints.len();
726        let mut connection = {
727            let mut pool = self.inner.transport.inner.pool.lock().await;
728            // Every endpoint in the exact transport identity serves the same
729            // cell. Reuse any idle connection so endpoint rotation cannot
730            // create more live sockets than the transport connection bound.
731            // The public gateway bounds how long it waits for the next
732            // prelude, so an indefinitely idle pooled stream may already be
733            // half-closed. Retire aged entries before a request can cross an
734            // indeterminate write boundary on one of them.
735            let now = tokio::time::Instant::now();
736            let connection = loop {
737                match pool.pop() {
738                    Some(connection) if connection_idle_is_reusable(connection.idle_since, now) => {
739                        break Some(connection);
740                    }
741                    Some(_) => {}
742                    None => break None,
743                }
744            };
745            self.inner
746                .transport
747                .inner
748                .metrics
749                .idle_connections
750                .store(pool.len(), Ordering::Relaxed);
751            connection
752        };
753        if connection.is_none() {
754            connection = Some(self.connect_native(endpoint_index).await?);
755        }
756        let mut connection = connection.ok_or(ClientError::Transport {
757            kind: TransportErrorKind::Connect,
758            disposition: SubmissionDisposition::NotSubmitted,
759        })?;
760        started.store(true, Ordering::Release);
761        if connection.stream.write_all(wire.as_slice()).await.is_err() || connection.stream.flush().await.is_err() {
762            return Err(ClientError::Transport {
763                kind: TransportErrorKind::Write,
764                disposition: SubmissionDisposition::Indeterminate,
765            });
766        }
767        let response_length = connection.stream.read_u32().await.map_err(|_| ClientError::Transport {
768            kind: TransportErrorKind::Read,
769            disposition: SubmissionDisposition::Indeterminate,
770        })?;
771        let response_length = usize::try_from(response_length).map_err(|_| ClientError::ResponseTooLarge)?;
772        if response_length == 0 || response_length > MAX_NATIVE_RESPONSE_BYTES {
773            return Err(ClientError::ResponseTooLarge);
774        }
775        let mut response = vec![0; response_length];
776        connection
777            .stream
778            .read_exact(&mut response)
779            .await
780            .map_err(|_| ClientError::Transport {
781                kind: TransportErrorKind::Read,
782                disposition: SubmissionDisposition::Indeterminate,
783            })?;
784        let decoded: ResponseEnvelope = serde_json::from_slice(&response).map_err(|_| ClientError::ResponseCodec)?;
785        if serde_json::to_vec(&decoded).map_err(|_| ClientError::ResponseCodec)? != response {
786            return Err(ClientError::NonCanonicalResponse);
787        }
788        let result = match decoded {
789            ResponseEnvelope::Success {
790                format_version,
791                request_id: observed,
792                result,
793            } if format_version == LODESTONE_API_FORMAT_VERSION && observed == request_id => {
794                if command_for_result(&result) != command {
795                    Err(ClientError::ResponseCommandMismatch)
796                } else {
797                    Ok(*result)
798                }
799            }
800            ResponseEnvelope::Success { .. } => Err(ClientError::ResponseIdentityMismatch),
801            ResponseEnvelope::Error {
802                format_version,
803                request_id: observed,
804                error,
805            } => {
806                if format_version != LODESTONE_API_FORMAT_VERSION || observed != request_id {
807                    return Err(ClientError::ResponseIdentityMismatch);
808                }
809                validate_error_body(&error).map_err(|_| ClientError::InvalidErrorShape)?;
810                let semantic = LODESTONE_API_COMMAND_SEMANTICS
811                    .iter()
812                    .find(|contract| contract.command == command)
813                    .ok_or(ClientError::MissingCommandContract)?;
814                if !semantic.admissible_errors.contains(&error.code) || server_error_disposition(&error).is_none() {
815                    return Err(ClientError::InvalidErrorShape);
816                }
817                Err(ClientError::Server(error))
818            }
819        };
820        // A success proves that the peer completed the request while keeping
821        // the shared byte stream synchronized. A typed refusal does not prove
822        // that the gateway kept the TLS session open: admission may close the
823        // connection immediately after writing the refusal. Discard refusal
824        // connections so a dependent follow-up (for example GetIndex
825        // NotFound followed by PrepareCreateIndex) cannot reuse a half-closed
826        // stream and consume its entire deadline waiting for a response.
827        if response_keeps_connection(&result) {
828            let mut pool = self.inner.transport.inner.pool.lock().await;
829            if pool.len() < self.pool_config().maximum_idle_connections {
830                connection.idle_since = tokio::time::Instant::now();
831                pool.push(connection);
832                self.inner
833                    .transport
834                    .inner
835                    .metrics
836                    .idle_connections
837                    .store(pool.len(), Ordering::Relaxed);
838            }
839        }
840        result
841    }
842
843    async fn connect_native(&self, endpoint_index: usize) -> Result<NativeConnection, ClientError> {
844        let endpoint = self
845            .inner
846            .transport
847            .inner
848            .config
849            .native_endpoints
850            .get(endpoint_index)
851            .ok_or(ClientError::InvalidEndpoint)?;
852        let stream = tokio::time::timeout(
853            self.pool_config().connect_timeout,
854            TcpStream::connect((endpoint.host(), endpoint.port())),
855        )
856        .await
857        .map_err(|_| ClientError::Transport {
858            kind: TransportErrorKind::Connect,
859            disposition: SubmissionDisposition::NotSubmitted,
860        })?
861        .map_err(|_| ClientError::Transport {
862            kind: TransportErrorKind::Connect,
863            disposition: SubmissionDisposition::NotSubmitted,
864        })?;
865        let server_name = ServerName::try_from(self.inner.transport.inner.config.identity.tls_server_name().to_owned())
866            .map_err(|_| ClientError::Config(ClientConfigError::InvalidServerName))?;
867        let connector = TlsConnector::from(Arc::clone(&self.inner.transport.inner.tls));
868        let stream = tokio::time::timeout(
869            self.pool_config().connect_timeout,
870            connector.connect(server_name, stream),
871        )
872        .await
873        .map_err(|_| ClientError::Transport {
874            kind: TransportErrorKind::Tls,
875            disposition: SubmissionDisposition::NotSubmitted,
876        })?
877        .map_err(|_| ClientError::Transport {
878            kind: TransportErrorKind::Tls,
879            disposition: SubmissionDisposition::NotSubmitted,
880        })?;
881        if stream.get_ref().1.alpn_protocol() != Some(LODESTONE_API_ALPN.as_bytes()) {
882            return Err(ClientError::Transport {
883                kind: TransportErrorKind::Tls,
884                disposition: SubmissionDisposition::NotSubmitted,
885            });
886        }
887        self.inner
888            .transport
889            .inner
890            .metrics
891            .live_connections
892            .fetch_add(1, Ordering::Relaxed);
893        Ok(NativeConnection {
894            stream,
895            metrics: Arc::clone(&self.inner.transport.inner.metrics),
896            idle_since: tokio::time::Instant::now(),
897        })
898    }
899
900    pub(crate) async fn credential(&self) -> Result<CredentialMaterial, ClientError> {
901        let profile = self.inner.config.profile.as_wire();
902        self.inner
903            .transport
904            .inner
905            .metrics
906            .credential_fetches
907            .fetch_add(1, Ordering::Relaxed);
908        let material = self
909            .inner
910            .config
911            .credentials
912            .provide(&profile.secret_reference)
913            .await
914            .map_err(ClientError::Credential)?;
915        if material.api_key_id() != &profile.credential_id || material.generation() != &profile.credential_generation {
916            return Err(ClientError::Credential(CredentialProviderError::InvalidMaterial));
917        }
918        Ok(material)
919    }
920
921    pub(crate) fn transport_config(&self) -> &CellTransportConfig {
922        &self.inner.transport.inner.config
923    }
924
925    pub(crate) fn pool_config(&self) -> PoolConfig {
926        self.inner.transport.pool_config()
927    }
928}
929
930fn response_keeps_connection(result: &Result<ResultBody, ClientError>) -> bool {
931    result.is_ok()
932}
933
934const MAX_REUSABLE_CONNECTION_IDLE: Duration = Duration::from_secs(1);
935
936fn connection_idle_is_reusable(idle_since: tokio::time::Instant, now: tokio::time::Instant) -> bool {
937    now.checked_duration_since(idle_since)
938        .is_some_and(|idle| idle <= MAX_REUSABLE_CONNECTION_IDLE)
939}
940
941const fn default_workload_class(action: Action) -> WorkloadClass {
942    match action {
943        Action::DataRead | Action::SubscriptionRead => WorkloadClass::InteractiveSearch,
944        Action::DataWrite | Action::Append | Action::ResolveAppend => WorkloadClass::StreamingIngest,
945        Action::IdentityDiscovery
946        | Action::IndexAdmin
947        | Action::CatalogRead
948        | Action::TaskRead
949        | Action::DiagnosticsRead
950        | Action::DiagnosticsCreate
951        | Action::BackupRead
952        | Action::BackupAdmin
953        | Action::SecurityRead
954        | Action::SecurityAdmin
955        | Action::AuditRead
956        | Action::QuotaRead
957        | Action::QuotaAdmin
958        | Action::BindingRead
959        | Action::LifecycleAdmin
960        | Action::AdmissionRead
961        | Action::AdmissionAdmin
962        | Action::UsageRead
963        | Action::OperationRead
964        | Action::OperationCancel => WorkloadClass::BatchAnalytics,
965    }
966}
967
968const fn retry_admitted(mutation: bool, pre_submit_retry_safe: bool, disposition: SubmissionDisposition) -> bool {
969    pre_submit_retry_safe && (!mutation || matches!(disposition, SubmissionDisposition::NotSubmitted))
970}
971
972#[derive(serde::Serialize)]
973struct PublicSession<'a> {
974    format_version: u16,
975    api_key_credential: &'a [u8],
976    collection_id: &'a str,
977    request_id: &'a str,
978    action: &'static str,
979    workload_class: WorkloadClass,
980    #[serde(skip_serializing_if = "Option::is_none")]
981    index_name: Option<&'a str>,
982    request_correlation_id: [u8; 16],
983}
984
985fn canonical_request(envelope: &RequestEnvelope) -> Result<Vec<u8>, ClientError> {
986    if envelope.meta.request_id.is_empty()
987        || envelope.meta.request_id.len() > MAX_REQUEST_ID_BYTES
988        || envelope.meta.request_id.bytes().any(|byte| byte.is_ascii_control())
989    {
990        return Err(ClientError::InvalidRequestIdentity);
991    }
992    let payload = serde_json::to_vec(envelope).map_err(|_| ClientError::RequestCodec)?;
993    if payload.is_empty() || payload.len() > MAX_NATIVE_REQUEST_BYTES {
994        return Err(ClientError::RequestTooLarge);
995    }
996    let decoded: RequestEnvelope = serde_json::from_slice(&payload).map_err(|_| ClientError::RequestCodec)?;
997    if decoded != *envelope {
998        return Err(ClientError::RequestCodec);
999    }
1000    Ok(payload)
1001}
1002
1003fn build_tls_config(config: &CellTransportConfig) -> Result<rustls::ClientConfig, ClientError> {
1004    let mut roots = rustls::RootCertStore::empty();
1005    for certificate in config.trust.certificates_der() {
1006        roots
1007            .add(CertificateDer::from(certificate.clone()))
1008            .map_err(|_| ClientError::Config(ClientConfigError::InvalidTrustMaterial))?;
1009    }
1010    let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
1011    let mut tls = rustls::ClientConfig::builder_with_provider(provider)
1012        .with_protocol_versions(&[&rustls::version::TLS13])
1013        .map_err(|_| ClientError::Config(ClientConfigError::InvalidTrustMaterial))?
1014        .with_root_certificates(roots)
1015        .with_no_client_auth();
1016    tls.alpn_protocols = vec![LODESTONE_API_ALPN.as_bytes().to_vec()];
1017    Ok(tls)
1018}
1019
1020fn validate_discovery(config: &CollectionSessionConfig, discovered: &DiscoverResult) -> Result<(), ClientError> {
1021    let profile = config.profile.as_wire();
1022    if discovered.collection_id != profile.expected_collection_id
1023        || discovered.principal_id != profile.expected_principal_id
1024        || discovered.schema_id != LODESTONE_API_SCHEMA_ID
1025        || discovered.schema_digest.value != LODESTONE_API_SPEC_SHA256
1026    {
1027        return Err(ClientError::ProfileIdentityMismatch);
1028    }
1029    let expected = LODESTONE_API_STREAM_CONTRACTS
1030        .first()
1031        .ok_or(ClientError::MissingCommandContract)?;
1032    if discovered.streams.len() != 1
1033        || discovered.streams.first().is_none_or(|stream| {
1034            stream.name != expected.name
1035                || stream.transport != expected.transport
1036                || stream.service != expected.service
1037                || stream.method != expected.method
1038                || stream.endpoint != profile.selected_stream_endpoint
1039                || stream.auth_metadata != expected.auth_metadata
1040                || stream.auth_scheme != expected.auth_scheme
1041                || stream.semantic_format_version != u32::from(expected.semantic_format_version)
1042                || stream.schema_digest.value != LODESTONE_API_SPEC_SHA256
1043        })
1044    {
1045        return Err(ClientError::ProfileStreamMismatch);
1046    }
1047    Ok(())
1048}
1049
1050fn validate_binding(
1051    config: &CollectionSessionConfig,
1052    binding: &CoreBinding,
1053    derived: bool,
1054    revision: &U64,
1055) -> Result<(), ClientError> {
1056    let profile = config.profile.as_wire();
1057    if !derived
1058        || binding.cluster_id != profile.expected_cluster_id
1059        || binding.collection_id != profile.expected_collection_id
1060        || binding.native_endpoints != profile.native_endpoints
1061        || binding.selected_stream_endpoint != profile.selected_stream_endpoint
1062        || binding.schema_id != profile.schema_id
1063        || binding.schema_digest != profile.schema_digest
1064        || binding
1065            .configuration_generation
1066            .value
1067            .parse::<u64>()
1068            .ok()
1069            .is_none_or(|generation| generation == 0)
1070        || revision.value.parse::<u64>().ok().is_none_or(|revision| revision == 0)
1071    {
1072        return Err(ClientError::ProfileIdentityMismatch);
1073    }
1074    Ok(())
1075}
1076
1077fn validate_cached_verification(
1078    config: &CollectionSessionConfig,
1079    verification: &ProfileVerification,
1080) -> Result<(), ClientError> {
1081    let profile = config.profile.as_wire();
1082    if verification.collection_id != profile.expected_collection_id
1083        || verification.principal_id != profile.expected_principal_id
1084        || verification.cluster_id != profile.expected_cluster_id
1085        || verification.schema_id != profile.schema_id
1086        || verification.schema_digest != profile.schema_digest
1087        || verification.native_endpoints != profile.native_endpoints
1088        || verification.selected_stream_endpoint != profile.selected_stream_endpoint
1089        || verification
1090            .configuration_generation
1091            .value
1092            .parse::<u64>()
1093            .ok()
1094            .is_none_or(|generation| generation == 0)
1095        || verification
1096            .binding_revision
1097            .value
1098            .parse::<u64>()
1099            .ok()
1100            .is_none_or(|revision| revision == 0)
1101    {
1102        return Err(ClientError::ProfileIdentityMismatch);
1103    }
1104    Ok(())
1105}
1106
1107fn resolve_index_name(request: &RequestBody, supplied: Option<&str>) -> Result<Option<String>, ClientError> {
1108    let scope = request_collection_index_ref(request)?;
1109    let inferred = match &scope {
1110        GeneratedCollectionIndexRef::Exact(name) => Some(name.clone()),
1111        GeneratedCollectionIndexRef::None | GeneratedCollectionIndexRef::Caller => None,
1112    };
1113    if let Some(supplied) = supplied {
1114        validate_index_name(supplied)?;
1115        if inferred.as_deref().is_some_and(|inferred| inferred != supplied) {
1116            return Err(ClientError::CollectionIndexRefMismatch);
1117        }
1118    }
1119    let resolved = supplied.map(str::to_owned).or(inferred);
1120    if let Some(name) = &resolved {
1121        validate_index_name(name)?;
1122    }
1123    match scope {
1124        GeneratedCollectionIndexRef::Caller | GeneratedCollectionIndexRef::Exact(_) if resolved.is_none() => {
1125            return Err(ClientError::CollectionIndexRefRequired);
1126        }
1127        GeneratedCollectionIndexRef::None if resolved.is_some() => return Err(ClientError::CollectionIndexRefMismatch),
1128        GeneratedCollectionIndexRef::None
1129        | GeneratedCollectionIndexRef::Caller
1130        | GeneratedCollectionIndexRef::Exact(_) => {}
1131    }
1132    Ok(resolved)
1133}
1134
1135const fn action_name(action: Action) -> &'static str {
1136    match action {
1137        Action::IdentityDiscovery => "identity_discovery",
1138        Action::DataRead => "data_read",
1139        Action::DataWrite => "data_write",
1140        Action::Append => "append",
1141        Action::ResolveAppend => "resolve_append",
1142        Action::IndexAdmin => "index_admin",
1143        Action::CatalogRead => "catalog_read",
1144        Action::TaskRead => "task_read",
1145        Action::SubscriptionRead => "subscription_read",
1146        Action::DiagnosticsRead => "diagnostics_read",
1147        Action::DiagnosticsCreate => "diagnostics_create",
1148        Action::BackupRead => "backup_read",
1149        Action::BackupAdmin => "backup_admin",
1150        Action::SecurityRead => "security_read",
1151        Action::SecurityAdmin => "security_admin",
1152        Action::AuditRead => "audit_read",
1153        Action::QuotaRead => "quota_read",
1154        Action::QuotaAdmin => "quota_admin",
1155        Action::BindingRead => "binding_read",
1156        Action::LifecycleAdmin => "lifecycle_admin",
1157        Action::AdmissionRead => "admission_read",
1158        Action::AdmissionAdmin => "admission_admin",
1159        Action::UsageRead => "usage_read",
1160        Action::OperationRead => "operation_read",
1161        Action::OperationCancel => "operation_cancel",
1162    }
1163}
1164
1165fn native_credential(material: &CredentialMaterial) -> Result<SensitiveBuffer, ClientError> {
1166    let identifier = parse_identity(&material.api_key_id().value)
1167        .ok_or(ClientError::Credential(CredentialProviderError::InvalidMaterial))?;
1168    let mut output = SensitiveBuffer::new();
1169    output.extend(identifier.to_string().as_bytes());
1170    output.extend(b":");
1171    output.extend(&base64_url_no_pad(material.secret().expose()));
1172    Ok(output)
1173}
1174
1175pub(crate) fn authorization_value(material: &CredentialMaterial) -> Result<SensitiveBuffer, ClientError> {
1176    let native = native_credential(material)?;
1177    let mut output = SensitiveBuffer::new();
1178    output.extend(b"ApiKey ");
1179    output.extend(&base64_standard(native.as_slice()));
1180    Ok(output)
1181}
1182
1183fn base64_url_no_pad(bytes: &[u8]) -> Vec<u8> {
1184    base64_encode(
1185        bytes,
1186        b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_",
1187        false,
1188    )
1189}
1190
1191fn base64_standard(bytes: &[u8]) -> Vec<u8> {
1192    base64_encode(
1193        bytes,
1194        b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",
1195        true,
1196    )
1197}
1198
1199/// Converts exact UTF-8 text into the authoritative Lodestone API raw JSON wire
1200/// leaf without parsing or normalizing the text.
1201pub fn raw_json_text(text: &str) -> Result<RawJson, ClientError> {
1202    let bytes_base64 = String::from_utf8(base64_standard(text.as_bytes())).map_err(|_| ClientError::RequestCodec)?;
1203    Ok(RawJson { bytes_base64 })
1204}
1205
1206fn base64_encode(bytes: &[u8], alphabet: &[u8; 64], padded: bool) -> Vec<u8> {
1207    let mut output = Vec::with_capacity(bytes.len().div_ceil(3).saturating_mul(4));
1208    for chunk in bytes.chunks(3) {
1209        let first = chunk[0];
1210        let second = chunk.get(1).copied().unwrap_or(0);
1211        let third = chunk.get(2).copied().unwrap_or(0);
1212        output.push(alphabet[usize::from(first >> 2)]);
1213        output.push(alphabet[usize::from(((first & 0x03) << 4) | (second >> 4))]);
1214        if chunk.len() > 1 {
1215            output.push(alphabet[usize::from(((second & 0x0f) << 2) | (third >> 6))]);
1216        } else if padded {
1217            output.push(b'=');
1218        }
1219        if chunk.len() > 2 {
1220            output.push(alphabet[usize::from(third & 0x3f)]);
1221        } else if padded {
1222            output.push(b'=');
1223        }
1224    }
1225    output
1226}
1227
1228fn random_identity() -> Result<[u8; 16], ClientError> {
1229    let mut output = [0; 16];
1230    getrandom::fill(&mut output).map_err(|_| ClientError::RandomUnavailable)?;
1231    if output == [0; 16] {
1232        return Err(ClientError::RandomUnavailable);
1233    }
1234    Ok(output)
1235}
1236
1237fn normalized_timeout(timeout: Duration) -> Result<Duration, ClientError> {
1238    if timeout.is_zero() || timeout > MAX_CALL_TIMEOUT {
1239        return Err(ClientError::InvalidCallOptions);
1240    }
1241    let mut millis = timeout.as_millis();
1242    if !timeout.subsec_nanos().is_multiple_of(1_000_000) {
1243        millis = millis.checked_add(1).ok_or(ClientError::InvalidCallOptions)?;
1244    }
1245    let millis = u64::try_from(millis).map_err(|_| ClientError::InvalidCallOptions)?;
1246    let normalized = Duration::from_millis(millis);
1247    if normalized > MAX_CALL_TIMEOUT {
1248        return Err(ClientError::InvalidCallOptions);
1249    }
1250    Ok(normalized)
1251}
1252
1253fn duration_millis_ceil(duration: Duration) -> Result<u64, ClientError> {
1254    let mut millis = duration.as_millis();
1255    if !duration.subsec_nanos().is_multiple_of(1_000_000) {
1256        millis = millis.checked_add(1).ok_or(ClientError::ClockUnavailable)?;
1257    }
1258    u64::try_from(millis).map_err(|_| ClientError::ClockUnavailable)
1259}
1260
1261pub(crate) fn validate_index_name(value: &str) -> Result<(), ClientError> {
1262    if value.is_empty()
1263        || value.len() > 255
1264        || value
1265            .bytes()
1266            .any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace())
1267    {
1268        return Err(ClientError::InvalidCollectionIndexRef);
1269    }
1270    Ok(())
1271}
1272
1273fn lower_hex(bytes: &[u8]) -> String {
1274    const HEX: &[u8; 16] = b"0123456789abcdef";
1275    let mut output = String::with_capacity(bytes.len().saturating_mul(2));
1276    for byte in bytes {
1277        output.push(char::from(HEX[usize::from(byte >> 4)]));
1278        output.push(char::from(HEX[usize::from(byte & 0x0f)]));
1279    }
1280    output
1281}
1282
1283pub(crate) struct SensitiveBuffer(Vec<u8>);
1284
1285impl SensitiveBuffer {
1286    fn new() -> Self {
1287        Self(Vec::new())
1288    }
1289
1290    fn extend(&mut self, bytes: &[u8]) {
1291        self.0.extend_from_slice(bytes);
1292    }
1293
1294    fn extend_u32(&mut self, value: usize) -> Result<(), ClientError> {
1295        let value = u32::try_from(value).map_err(|_| ClientError::RequestTooLarge)?;
1296        self.extend(&value.to_be_bytes());
1297        Ok(())
1298    }
1299
1300    fn is_empty(&self) -> bool {
1301        self.0.is_empty()
1302    }
1303
1304    fn len(&self) -> usize {
1305        self.0.len()
1306    }
1307
1308    pub(crate) fn as_slice(&self) -> &[u8] {
1309        &self.0
1310    }
1311}
1312
1313impl Drop for SensitiveBuffer {
1314    fn drop(&mut self) {
1315        self.0.fill(0);
1316        std::hint::black_box(&mut self.0);
1317    }
1318}
1319
1320fn started_disposition(started: &AtomicBool) -> SubmissionDisposition {
1321    if started.load(Ordering::Acquire) {
1322        SubmissionDisposition::Indeterminate
1323    } else {
1324        SubmissionDisposition::NotSubmitted
1325    }
1326}
1327
1328#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1329pub enum SubmissionDisposition {
1330    NotSubmitted,
1331    Submitted,
1332    Resolved,
1333    KnownRetired,
1334    Indeterminate,
1335}
1336
1337#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1338pub enum TransportErrorKind {
1339    PoolClosed,
1340    Connect,
1341    Tls,
1342    Write,
1343    Read,
1344    Deadline,
1345    Cancelled,
1346}
1347
1348#[derive(Debug)]
1349pub enum ClientError {
1350    Config(ClientConfigError),
1351    Credential(CredentialProviderError),
1352    InvalidCallOptions,
1353    InvalidEndpoint,
1354    InvalidCollectionIndexRef,
1355    InvalidStateIndex,
1356    InvalidStateActionId,
1357    InvalidStateKey,
1358    InvalidStateBatch,
1359    UnexpectedStateResult,
1360    CollectionIndexRefRequired,
1361    CollectionIndexRefMismatch,
1362    ProfileNotVerified,
1363    ProfileIdentityMismatch,
1364    ProfileStreamMismatch,
1365    MissingCommandContract,
1366    RequestCodec,
1367    RequestTooLarge,
1368    InvalidRequestIdentity,
1369    ResponseCodec,
1370    ResponseTooLarge,
1371    NonCanonicalResponse,
1372    ResponseIdentityMismatch,
1373    ResponseCommandMismatch,
1374    InvalidErrorShape,
1375    RandomUnavailable,
1376    ClockUnavailable,
1377    InternalStateUnavailable,
1378    Transport {
1379        kind: TransportErrorKind,
1380        disposition: SubmissionDisposition,
1381    },
1382    Server(ErrorBody),
1383}
1384
1385impl ClientError {
1386    #[must_use]
1387    pub fn submission_disposition(&self) -> SubmissionDisposition {
1388        match self {
1389            Self::Transport { disposition, .. } => *disposition,
1390            Self::Server(error) => match server_error_disposition(error) {
1391                Some(disposition) => disposition,
1392                None => SubmissionDisposition::Indeterminate,
1393            },
1394            Self::UnexpectedStateResult
1395            | Self::ResponseCodec
1396            | Self::ResponseTooLarge
1397            | Self::NonCanonicalResponse
1398            | Self::ResponseIdentityMismatch
1399            | Self::ResponseCommandMismatch
1400            | Self::InvalidErrorShape => SubmissionDisposition::Indeterminate,
1401            _ => SubmissionDisposition::NotSubmitted,
1402        }
1403    }
1404
1405    pub(crate) fn is_deadline_exceeded(&self) -> bool {
1406        matches!(
1407            self,
1408            Self::Transport {
1409                kind: TransportErrorKind::Deadline,
1410                ..
1411            } | Self::Server(ErrorBody {
1412                code: lodestone_wire::ErrorCode::DeadlineExceeded,
1413                ..
1414            })
1415        )
1416    }
1417}
1418
1419fn server_error_disposition(error: &ErrorBody) -> Option<SubmissionDisposition> {
1420    let contract = LODESTONE_API_ERROR_OUTCOMES
1421        .iter()
1422        .find(|contract| contract.code == error.code)?;
1423    let value = if error.submitted {
1424        contract.when_submitted
1425    } else {
1426        contract.when_not_submitted
1427    }?;
1428    match value {
1429        "not_submitted" => Some(SubmissionDisposition::NotSubmitted),
1430        "submitted" => Some(SubmissionDisposition::Submitted),
1431        "resolved" => Some(SubmissionDisposition::Resolved),
1432        "known_retired" => Some(SubmissionDisposition::KnownRetired),
1433        "indeterminate" => Some(SubmissionDisposition::Indeterminate),
1434        _ => None,
1435    }
1436}
1437
1438impl From<ClientConfigError> for ClientError {
1439    fn from(error: ClientConfigError) -> Self {
1440        Self::Config(error)
1441    }
1442}
1443
1444impl Display for ClientError {
1445    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
1446        formatter.write_str(match self {
1447            Self::Config(_) => "Core client configuration is invalid",
1448            Self::Credential(_) => "Core credential material is unavailable or mismatched",
1449            Self::InvalidCallOptions => "Core call options are invalid",
1450            Self::InvalidEndpoint => "Core endpoint is invalid",
1451            Self::InvalidCollectionIndexRef => "Core request index scope is invalid",
1452            Self::InvalidStateIndex => "Lodestone state index identity is invalid",
1453            Self::InvalidStateActionId => "Lodestone state action identity is invalid",
1454            Self::InvalidStateKey => "Lodestone state key must contain 1 to 254 bytes",
1455            Self::InvalidStateBatch => "Lodestone state key batch is empty or exceeds its bound",
1456            Self::UnexpectedStateResult => "Lodestone state result differs from the requested operation",
1457            Self::CollectionIndexRefRequired => "Core request requires an exact index scope",
1458            Self::CollectionIndexRefMismatch => "Core request and admission index scopes differ",
1459            Self::ProfileNotVerified => "Core mutation is refused until discovery and binding are verified",
1460            Self::ProfileIdentityMismatch => "Core discovery or binding differs from the connection profile",
1461            Self::ProfileStreamMismatch => "Core selected stream differs from the connection profile",
1462            Self::MissingCommandContract => "generated Core command metadata is incomplete",
1463            Self::RequestCodec => "Core request is not a canonical current Lodestone API value",
1464            Self::RequestTooLarge => "Core request exceeds its generated byte bound",
1465            Self::InvalidRequestIdentity => "Core request identity is invalid",
1466            Self::ResponseCodec => "Core response is malformed or unsupported",
1467            Self::ResponseTooLarge => "Core response exceeds its generated byte bound",
1468            Self::NonCanonicalResponse => "Core response encoding is noncanonical",
1469            Self::ResponseIdentityMismatch => "Core response changed the request identity or version",
1470            Self::ResponseCommandMismatch => "Core response returned another command result",
1471            Self::InvalidErrorShape => "Core error detail does not match its generated code",
1472            Self::RandomUnavailable => "secure request identity generation is unavailable",
1473            Self::ClockUnavailable => "absolute Core request deadline cannot be calculated",
1474            Self::InternalStateUnavailable => "Core client verification state is unavailable",
1475            Self::Transport { .. } => "Core transport failed",
1476            Self::Server(_) => "Core returned a typed request error",
1477        })
1478    }
1479}
1480
1481impl Error for ClientError {}
1482
1483#[cfg(test)]
1484mod tests {
1485    use std::error::Error;
1486
1487    use lodestone_wire::{GetIndexRequest, Identity, OpenPitRequest, Resource, U64};
1488
1489    use super::*;
1490    use crate::SecretBytes;
1491
1492    #[test]
1493    fn response_failures_preserve_indeterminate_submission() {
1494        for error in [
1495            ClientError::UnexpectedStateResult,
1496            ClientError::ResponseCodec,
1497            ClientError::ResponseTooLarge,
1498            ClientError::NonCanonicalResponse,
1499            ClientError::ResponseIdentityMismatch,
1500            ClientError::ResponseCommandMismatch,
1501            ClientError::InvalidErrorShape,
1502        ] {
1503            assert_eq!(error.submission_disposition(), SubmissionDisposition::Indeterminate);
1504        }
1505        assert_eq!(
1506            ClientError::RequestCodec.submission_disposition(),
1507            SubmissionDisposition::NotSubmitted
1508        );
1509    }
1510
1511    fn cache_key(collection: u128, principal: u128, credential: u128) -> VerifiedSessionCacheKey {
1512        VerifiedSessionCacheKey {
1513            collection_id: format!("{collection:032x}"),
1514            principal_id: format!("{principal:032x}"),
1515            profile_generation: "1".to_owned(),
1516            credential_id: format!("{credential:032x}"),
1517            credential_generation: "1".to_owned(),
1518            secret_reference: format!("memory://session/{collection}/{principal}/{credential}"),
1519        }
1520    }
1521
1522    fn verification(collection: u128, principal: u128) -> ProfileVerification {
1523        ProfileVerification {
1524            artifact_version: "development".to_owned(),
1525            principal_id: Identity {
1526                value: format!("{principal:032x}"),
1527            },
1528            collection_id: Identity {
1529                value: format!("{collection:032x}"),
1530            },
1531            policy_revision: U64 { value: "1".to_owned() },
1532            cluster_id: Identity {
1533                value: "00000000000000000000000000000001".to_owned(),
1534            },
1535            configuration_generation: U64 { value: "1".to_owned() },
1536            binding_revision: U64 { value: "1".to_owned() },
1537            schema_id: LODESTONE_API_SCHEMA_ID.to_owned(),
1538            schema_digest: lodestone_wire::Digest {
1539                value: LODESTONE_API_SPEC_SHA256.to_owned(),
1540            },
1541            native_endpoints: vec!["lodestone+tls://core.example:8443".to_owned()],
1542            selected_stream_endpoint: "https://core.example:9443".to_owned(),
1543        }
1544    }
1545
1546    #[test]
1547    fn mutation_retry_stops_at_the_submission_boundary() {
1548        assert!(retry_admitted(true, true, SubmissionDisposition::NotSubmitted));
1549        assert!(!retry_admitted(true, true, SubmissionDisposition::Submitted));
1550        assert!(!retry_admitted(true, true, SubmissionDisposition::Indeterminate));
1551        assert!(!retry_admitted(true, false, SubmissionDisposition::NotSubmitted));
1552        assert!(retry_admitted(false, true, SubmissionDisposition::Indeterminate));
1553    }
1554
1555    #[test]
1556    fn one_deadline_survives_connect_state_and_resolution_clones() -> Result<(), Box<dyn Error>> {
1557        let options = CallOptions::new(Duration::from_secs(5))?;
1558        let absolute = options.absolute_deadline_unix_millis()?;
1559        let local = options.local_deadline()?;
1560
1561        // These clones stand for discovery, binding, prepare, submit, and
1562        // resolution. None may derive a fresh duration from the current time.
1563        for stage in [
1564            options.clone(),
1565            options.clone(),
1566            options.clone(),
1567            options.clone(),
1568            options.for_resolution(),
1569        ] {
1570            assert_eq!(stage.absolute_deadline_unix_millis()?, absolute);
1571            assert_eq!(stage.local_deadline()?, local);
1572        }
1573
1574        let resolution = options.for_resolution();
1575        options.cancellation().cancel();
1576        assert!(options.cancellation().is_cancelled());
1577        assert!(!resolution.cancellation().is_cancelled());
1578        assert_eq!(resolution.absolute_deadline_unix_millis()?, absolute);
1579        Ok(())
1580    }
1581
1582    #[test]
1583    fn caller_supplied_absolute_deadline_is_preserved_exactly() -> Result<(), Box<dyn Error>> {
1584        let now = SystemTime::now().duration_since(UNIX_EPOCH)?;
1585        let now_millis = u64::try_from(now.as_millis())?;
1586        let absolute = now_millis.checked_add(5_000).ok_or("test deadline overflow")?;
1587        let options = CallOptions::from_absolute_deadline_unix_millis(absolute)?;
1588        assert_eq!(options.absolute_deadline_unix_millis()?, absolute);
1589
1590        let too_far = now_millis
1591            .checked_add(u64::try_from(MAX_CALL_TIMEOUT.as_millis())?)
1592            .and_then(|value| value.checked_add(1_000))
1593            .ok_or("test deadline overflow")?;
1594        assert!(matches!(
1595            CallOptions::from_absolute_deadline_unix_millis(too_far),
1596            Err(ClientError::InvalidCallOptions)
1597        ));
1598        assert_eq!(normalized_timeout(Duration::from_nanos(1))?, Duration::from_millis(1));
1599        Ok(())
1600    }
1601
1602    #[test]
1603    fn private_api_key_encodings_match_the_core_admission_contract() -> Result<(), Box<dyn Error>> {
1604        let material = CredentialMaterial::new(
1605            Identity {
1606                value: "00000000000000000000000000000001".to_owned(),
1607            },
1608            U64 { value: "1".to_owned() },
1609            SecretBytes::from_array([0; 32]),
1610        )?;
1611        assert_eq!(
1612            native_credential(&material)?.as_slice(),
1613            b"1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
1614        );
1615        assert_eq!(
1616            authorization_value(&material)?.as_slice(),
1617            b"ApiKey MTpBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB"
1618        );
1619        Ok(())
1620    }
1621
1622    #[test]
1623    fn typed_refusal_discards_the_connection_before_a_dependent_follow_up() {
1624        let refusal = Err(ClientError::Server(ErrorBody {
1625            code: lodestone_wire::ErrorCode::PermissionDenied,
1626            message: "denied".to_owned(),
1627            retry_safe: false,
1628            submitted: false,
1629            details: None,
1630        }));
1631        assert!(!response_keeps_connection(&refusal));
1632        let failure = Err(ClientError::Transport {
1633            kind: TransportErrorKind::Read,
1634            disposition: SubmissionDisposition::Indeterminate,
1635        });
1636        assert!(!response_keeps_connection(&failure));
1637    }
1638
1639    #[test]
1640    fn connection_idle_age_is_fenced_below_the_gateway_prelude_timeout() {
1641        let now = tokio::time::Instant::now();
1642        assert!(connection_idle_is_reusable(now - MAX_REUSABLE_CONNECTION_IDLE, now));
1643        assert!(!connection_idle_is_reusable(
1644            now - MAX_REUSABLE_CONNECTION_IDLE - Duration::from_millis(1),
1645            now
1646        ));
1647    }
1648
1649    #[test]
1650    fn verified_session_cache_is_bounded_and_never_crosses_principals() {
1651        let metrics = CellTransportMetrics::default();
1652        let mut cache = VerifiedSessionCache::default();
1653        let first = cache_key(1, 10, 100);
1654        let other_principal = cache_key(1, 11, 100);
1655        assert_ne!(first, other_principal);
1656        cache.insert(first.clone(), verification(1, 10), &metrics);
1657        assert!(cache.get(&other_principal, &metrics).is_none());
1658        assert_eq!(cache.get(&first, &metrics), Some(verification(1, 10)));
1659
1660        for offset in 0..=MAX_VERIFIED_SESSION_METADATA {
1661            let identity = u128::try_from(offset).unwrap_or_default().saturating_add(1_000);
1662            cache.insert(
1663                cache_key(identity, identity, identity),
1664                verification(identity, identity),
1665                &metrics,
1666            );
1667        }
1668        assert_eq!(cache.entries.len(), MAX_VERIFIED_SESSION_METADATA);
1669        assert!(metrics.session_cache_evictions.load(Ordering::Relaxed) > 0);
1670        assert_eq!(
1671            metrics.session_cache_entries.load(Ordering::Relaxed),
1672            MAX_VERIFIED_SESSION_METADATA
1673        );
1674        assert_eq!(metrics.session_cache_hits.load(Ordering::Relaxed), 1);
1675        assert_eq!(metrics.session_cache_misses.load(Ordering::Relaxed), 1);
1676    }
1677
1678    #[test]
1679    fn generated_multi_index_scope_requires_a_nonempty_distinct_set() {
1680        fn index(name: &str, incarnation: &str) -> Resource {
1681            Resource {
1682                kind: "index".to_owned(),
1683                name: name.to_owned(),
1684                incarnation: Identity {
1685                    value: incarnation.to_owned(),
1686                },
1687            }
1688        }
1689
1690        fn request(indexes: Vec<Resource>) -> RequestBody {
1691            RequestBody::OpenPit(OpenPitRequest {
1692                indexes,
1693                keep_alive_millis: U64 {
1694                    value: "1000".to_owned(),
1695                },
1696            })
1697        }
1698
1699        let first = index("orders", "00000000000000000000000000000001");
1700        let second = index("events", "00000000000000000000000000000002");
1701        assert!(matches!(
1702            resolve_index_name(&request(Vec::new()), None),
1703            Err(ClientError::CollectionIndexRefRequired)
1704        ));
1705        assert!(matches!(
1706            resolve_index_name(&request(vec![first.clone(), second.clone()]), None),
1707            Ok(None)
1708        ));
1709        assert!(matches!(
1710            resolve_index_name(&request(vec![first.clone(), first.clone()]), None),
1711            Err(ClientError::CollectionIndexRefMismatch)
1712        ));
1713        assert!(matches!(
1714            resolve_index_name(&request(vec![second.clone(), first.clone(), first]), None),
1715            Err(ClientError::CollectionIndexRefMismatch)
1716        ));
1717    }
1718
1719    #[test]
1720    fn generated_get_index_is_collection_scoped() {
1721        let request = RequestBody::GetIndex(GetIndexRequest {
1722            name: "events".to_owned(),
1723            expected_incarnation: None,
1724        });
1725
1726        assert!(matches!(resolve_index_name(&request, None), Ok(None)));
1727        assert!(matches!(
1728            resolve_index_name(&request, Some("events")),
1729            Err(ClientError::CollectionIndexRefMismatch)
1730        ));
1731    }
1732}