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