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}
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        validate_discovery(&self.inner.config, discovered)?;
540        validate_binding(&self.inner.config, &binding.binding, binding.derived, &binding.revision)?;
541        let verified = ProfileVerification {
542            artifact_version: discovered.artifact_version.clone(),
543            principal_id: discovered.principal_id.clone(),
544            collection_id: discovered.collection_id.clone(),
545            policy_revision: discovered.policy_revision.clone(),
546            cluster_id: binding.binding.cluster_id.clone(),
547            configuration_generation: binding.binding.configuration_generation.clone(),
548            binding_revision: binding.revision.clone(),
549            schema_id: binding.binding.schema_id.clone(),
550            schema_digest: binding.binding.schema_digest.clone(),
551            native_endpoints: binding.binding.native_endpoints.clone(),
552            selected_stream_endpoint: binding.binding.selected_stream_endpoint.clone(),
553        };
554        let mut slot = self
555            .inner
556            .verified
557            .lock()
558            .map_err(|_| ClientError::InternalStateUnavailable)?;
559        *slot = Some(verified.clone());
560        self.inner
561            .transport
562            .inner
563            .verified_sessions
564            .lock()
565            .map_err(|_| ClientError::InternalStateUnavailable)?
566            .insert(
567                VerifiedSessionCacheKey::from_config(&self.inner.config),
568                verified.clone(),
569                &self.inner.transport.inner.metrics,
570            );
571        Ok(verified)
572    }
573
574    /// Dispatches one closed Lodestone API request body through the same validated
575    /// transport and recovery boundary used by every generated typed method.
576    ///
577    /// The caller supplies no envelope, collection, credential, or arbitrary
578    /// header. Those remain owned by the validated connection profile,
579    /// credential provider, and call options.
580    pub async fn call(&self, request: RequestBody, options: CallOptions) -> Result<ResultBody, ClientError> {
581        let local_deadline = options.local_deadline()?;
582        if tokio::time::Instant::now() >= local_deadline {
583            return Err(ClientError::Transport {
584                kind: TransportErrorKind::Deadline,
585                disposition: SubmissionDisposition::NotSubmitted,
586            });
587        }
588        let command = request_command(&request);
589        let command_contract = LODESTONE_API_COMMAND_CONTRACTS
590            .iter()
591            .find(|contract| contract.command == command)
592            .ok_or(ClientError::MissingCommandContract)?;
593        let outcome_contract = LODESTONE_API_COMMAND_OUTCOMES
594            .iter()
595            .find(|contract| contract.command == command)
596            .ok_or(ClientError::MissingCommandContract)?;
597        let mutation = outcome_contract.possible_submission_states.contains(&"submitted");
598        if mutation && self.profile_verification().is_none() {
599            return Err(ClientError::ProfileNotVerified);
600        }
601        let index_name = resolve_index_name(&request, options.index_name.as_deref())?;
602        let correlation = random_identity()?;
603        let deadline = options.absolute_deadline_unix_millis()?;
604        let request_id = format!("r-{}", lower_hex(&correlation));
605        let workload_class = options
606            .workload_class
607            .unwrap_or_else(|| default_workload_class(command_contract.action));
608        let envelope = RequestEnvelope {
609            meta: RequestMeta {
610                format_version: LODESTONE_API_FORMAT_VERSION,
611                request_id: request_id.clone(),
612                correlation_id: Identity {
613                    value: lower_hex(&correlation),
614                },
615                absolute_deadline_unix_millis: U64 {
616                    value: deadline.to_string(),
617                },
618                workload_class,
619            },
620            request,
621        };
622        let payload = canonical_request(&envelope)?;
623        let started = Arc::new(AtomicBool::new(false));
624        let maximum_attempts = usize::from(self.pool_config().maximum_read_retries).saturating_add(1);
625        let mut attempt = 0_usize;
626        loop {
627            attempt = attempt.saturating_add(1);
628            let result = tokio::select! {
629                result = tokio::time::timeout_at(
630                    local_deadline,
631                    self.transport_attempt(
632                        &payload,
633                        &request_id,
634                        command,
635                        command_contract.action,
636                        workload_class,
637                        index_name.as_deref(),
638                        correlation,
639                        Arc::clone(&started),
640                    ),
641                ) => match result {
642                    Ok(result) => result,
643                    Err(_) => Err(ClientError::Transport {
644                        kind: TransportErrorKind::Deadline,
645                        disposition: started_disposition(&started),
646                    }),
647                },
648                () = options.cancellation.cancelled() => Err(ClientError::Transport {
649                    kind: TransportErrorKind::Cancelled,
650                    disposition: started_disposition(&started),
651                }),
652            };
653            match result {
654                Err(ClientError::Transport { disposition, .. })
655                    if attempt < maximum_attempts
656                        && !options.cancellation.is_cancelled()
657                        && retry_admitted(mutation, outcome_contract.pre_submit_retry_safe, disposition) =>
658                {
659                    continue;
660                }
661                result => return result,
662            }
663        }
664    }
665
666    #[allow(clippy::too_many_arguments)]
667    async fn transport_attempt(
668        &self,
669        payload: &[u8],
670        request_id: &str,
671        command: Command,
672        action: Action,
673        workload_class: WorkloadClass,
674        index_name: Option<&str>,
675        correlation: [u8; 16],
676        started: Arc<AtomicBool>,
677    ) -> Result<ResultBody, ClientError> {
678        // INVARIANT: Collection-local credential failures cannot consume or
679        // discard a shared cell connection. Material remains request-local and
680        // is fully encoded before this call enters the transport bulkhead.
681        let credential = self.credential().await?;
682        let native_credential = native_credential(&credential)?;
683        let session = PublicSession {
684            format_version: PUBLIC_SESSION_FORMAT_VERSION,
685            api_key_credential: native_credential.as_slice(),
686            collection_id: &self.inner.config.profile.as_wire().expected_collection_id.value,
687            request_id,
688            action: action_name(action),
689            workload_class,
690            index_name,
691            request_correlation_id: correlation,
692        };
693        let session = SensitiveBuffer(serde_json::to_vec(&session).map_err(|_| ClientError::RequestCodec)?);
694        if session.is_empty() || session.len() > MAX_PUBLIC_SESSION_BYTES {
695            return Err(ClientError::RequestTooLarge);
696        }
697        let mut wire = SensitiveBuffer::new();
698        wire.extend_u32(session.len())?;
699        wire.extend(session.as_slice());
700        wire.extend_u32(payload.len())?;
701        wire.extend(payload);
702
703        if self.inner.transport.inner.connection_slots.available_permits() == 0 {
704            self.inner
705                .transport
706                .inner
707                .metrics
708                .bulkhead_saturation
709                .fetch_add(1, Ordering::Relaxed);
710        }
711        let _permit = self
712            .inner
713            .transport
714            .inner
715            .connection_slots
716            .clone()
717            .acquire_owned()
718            .await
719            .map_err(|_| ClientError::Transport {
720                kind: TransportErrorKind::PoolClosed,
721                disposition: SubmissionDisposition::NotSubmitted,
722            })?;
723        let endpoint_index = self.inner.transport.inner.next_endpoint.fetch_add(1, Ordering::Relaxed)
724            % self.inner.transport.inner.config.native_endpoints.len();
725        let mut connection = {
726            let mut pool = self.inner.transport.inner.pool.lock().await;
727            // Every endpoint in the exact transport identity serves the same
728            // cell. Reuse any idle connection so endpoint rotation cannot
729            // create more live sockets than the transport connection bound.
730            let connection = pool.pop();
731            self.inner
732                .transport
733                .inner
734                .metrics
735                .idle_connections
736                .store(pool.len(), Ordering::Relaxed);
737            connection
738        };
739        if connection.is_none() {
740            connection = Some(self.connect_native(endpoint_index).await?);
741        }
742        let mut connection = connection.ok_or(ClientError::Transport {
743            kind: TransportErrorKind::Connect,
744            disposition: SubmissionDisposition::NotSubmitted,
745        })?;
746        started.store(true, Ordering::Release);
747        if connection.stream.write_all(wire.as_slice()).await.is_err() || connection.stream.flush().await.is_err() {
748            return Err(ClientError::Transport {
749                kind: TransportErrorKind::Write,
750                disposition: SubmissionDisposition::Indeterminate,
751            });
752        }
753        let response_length = connection.stream.read_u32().await.map_err(|_| ClientError::Transport {
754            kind: TransportErrorKind::Read,
755            disposition: SubmissionDisposition::Indeterminate,
756        })?;
757        let response_length = usize::try_from(response_length).map_err(|_| ClientError::ResponseTooLarge)?;
758        if response_length == 0 || response_length > MAX_NATIVE_RESPONSE_BYTES {
759            return Err(ClientError::ResponseTooLarge);
760        }
761        let mut response = vec![0; response_length];
762        connection
763            .stream
764            .read_exact(&mut response)
765            .await
766            .map_err(|_| ClientError::Transport {
767                kind: TransportErrorKind::Read,
768                disposition: SubmissionDisposition::Indeterminate,
769            })?;
770        let decoded: ResponseEnvelope = serde_json::from_slice(&response).map_err(|_| ClientError::ResponseCodec)?;
771        if serde_json::to_vec(&decoded).map_err(|_| ClientError::ResponseCodec)? != response {
772            return Err(ClientError::NonCanonicalResponse);
773        }
774        let result = match decoded {
775            ResponseEnvelope::Success {
776                format_version,
777                request_id: observed,
778                result,
779            } if format_version == LODESTONE_API_FORMAT_VERSION && observed == request_id => {
780                if command_for_result(&result) != command {
781                    Err(ClientError::ResponseCommandMismatch)
782                } else {
783                    Ok(*result)
784                }
785            }
786            ResponseEnvelope::Success { .. } => Err(ClientError::ResponseIdentityMismatch),
787            ResponseEnvelope::Error {
788                format_version,
789                request_id: observed,
790                error,
791            } => {
792                if format_version != LODESTONE_API_FORMAT_VERSION || observed != request_id {
793                    return Err(ClientError::ResponseIdentityMismatch);
794                }
795                validate_error_body(&error).map_err(|_| ClientError::InvalidErrorShape)?;
796                let semantic = LODESTONE_API_COMMAND_SEMANTICS
797                    .iter()
798                    .find(|contract| contract.command == command)
799                    .ok_or(ClientError::MissingCommandContract)?;
800                if !semantic.admissible_errors.contains(&error.code) || server_error_disposition(&error).is_none() {
801                    return Err(ClientError::InvalidErrorShape);
802                }
803                Err(ClientError::Server(error))
804            }
805        };
806        // A success proves that the peer completed the request while keeping
807        // the shared byte stream synchronized. A typed refusal does not prove
808        // that the gateway kept the TLS session open: admission may close the
809        // connection immediately after writing the refusal. Discard refusal
810        // connections so a dependent follow-up (for example GetIndex
811        // NotFound followed by PrepareCreateIndex) cannot reuse a half-closed
812        // stream and consume its entire deadline waiting for a response.
813        if response_keeps_connection(&result) {
814            let mut pool = self.inner.transport.inner.pool.lock().await;
815            if pool.len() < self.pool_config().maximum_idle_connections {
816                pool.push(connection);
817                self.inner
818                    .transport
819                    .inner
820                    .metrics
821                    .idle_connections
822                    .store(pool.len(), Ordering::Relaxed);
823            }
824        }
825        result
826    }
827
828    async fn connect_native(&self, endpoint_index: usize) -> Result<NativeConnection, ClientError> {
829        let endpoint = self
830            .inner
831            .transport
832            .inner
833            .config
834            .native_endpoints
835            .get(endpoint_index)
836            .ok_or(ClientError::InvalidEndpoint)?;
837        let stream = tokio::time::timeout(
838            self.pool_config().connect_timeout,
839            TcpStream::connect((endpoint.host(), endpoint.port())),
840        )
841        .await
842        .map_err(|_| ClientError::Transport {
843            kind: TransportErrorKind::Connect,
844            disposition: SubmissionDisposition::NotSubmitted,
845        })?
846        .map_err(|_| ClientError::Transport {
847            kind: TransportErrorKind::Connect,
848            disposition: SubmissionDisposition::NotSubmitted,
849        })?;
850        let server_name = ServerName::try_from(self.inner.transport.inner.config.identity.tls_server_name().to_owned())
851            .map_err(|_| ClientError::Config(ClientConfigError::InvalidServerName))?;
852        let connector = TlsConnector::from(Arc::clone(&self.inner.transport.inner.tls));
853        let stream = tokio::time::timeout(
854            self.pool_config().connect_timeout,
855            connector.connect(server_name, stream),
856        )
857        .await
858        .map_err(|_| ClientError::Transport {
859            kind: TransportErrorKind::Tls,
860            disposition: SubmissionDisposition::NotSubmitted,
861        })?
862        .map_err(|_| ClientError::Transport {
863            kind: TransportErrorKind::Tls,
864            disposition: SubmissionDisposition::NotSubmitted,
865        })?;
866        if stream.get_ref().1.alpn_protocol() != Some(LODESTONE_API_ALPN.as_bytes()) {
867            return Err(ClientError::Transport {
868                kind: TransportErrorKind::Tls,
869                disposition: SubmissionDisposition::NotSubmitted,
870            });
871        }
872        self.inner
873            .transport
874            .inner
875            .metrics
876            .live_connections
877            .fetch_add(1, Ordering::Relaxed);
878        Ok(NativeConnection {
879            stream,
880            metrics: Arc::clone(&self.inner.transport.inner.metrics),
881        })
882    }
883
884    pub(crate) async fn credential(&self) -> Result<CredentialMaterial, ClientError> {
885        let profile = self.inner.config.profile.as_wire();
886        self.inner
887            .transport
888            .inner
889            .metrics
890            .credential_fetches
891            .fetch_add(1, Ordering::Relaxed);
892        let material = self
893            .inner
894            .config
895            .credentials
896            .provide(&profile.secret_reference)
897            .await
898            .map_err(ClientError::Credential)?;
899        if material.api_key_id() != &profile.credential_id || material.generation() != &profile.credential_generation {
900            return Err(ClientError::Credential(CredentialProviderError::InvalidMaterial));
901        }
902        Ok(material)
903    }
904
905    pub(crate) fn transport_config(&self) -> &CellTransportConfig {
906        &self.inner.transport.inner.config
907    }
908
909    pub(crate) fn pool_config(&self) -> PoolConfig {
910        self.inner.transport.pool_config()
911    }
912}
913
914fn response_keeps_connection(result: &Result<ResultBody, ClientError>) -> bool {
915    result.is_ok()
916}
917
918const fn default_workload_class(action: Action) -> WorkloadClass {
919    match action {
920        Action::DataRead | Action::SubscriptionRead => WorkloadClass::InteractiveSearch,
921        Action::DataWrite | Action::Append | Action::ResolveAppend => WorkloadClass::StreamingIngest,
922        Action::IdentityDiscovery
923        | Action::IndexAdmin
924        | Action::CatalogRead
925        | Action::TaskRead
926        | Action::DiagnosticsRead
927        | Action::DiagnosticsCreate
928        | Action::BackupRead
929        | Action::BackupAdmin
930        | Action::SecurityRead
931        | Action::SecurityAdmin
932        | Action::AuditRead
933        | Action::QuotaRead
934        | Action::QuotaAdmin
935        | Action::BindingRead
936        | Action::LifecycleAdmin
937        | Action::AdmissionRead
938        | Action::AdmissionAdmin
939        | Action::UsageRead
940        | Action::OperationRead
941        | Action::OperationCancel => WorkloadClass::BatchAnalytics,
942    }
943}
944
945const fn retry_admitted(mutation: bool, pre_submit_retry_safe: bool, disposition: SubmissionDisposition) -> bool {
946    pre_submit_retry_safe && (!mutation || matches!(disposition, SubmissionDisposition::NotSubmitted))
947}
948
949#[derive(serde::Serialize)]
950struct PublicSession<'a> {
951    format_version: u16,
952    api_key_credential: &'a [u8],
953    collection_id: &'a str,
954    request_id: &'a str,
955    action: &'static str,
956    workload_class: WorkloadClass,
957    #[serde(skip_serializing_if = "Option::is_none")]
958    index_name: Option<&'a str>,
959    request_correlation_id: [u8; 16],
960}
961
962fn canonical_request(envelope: &RequestEnvelope) -> Result<Vec<u8>, ClientError> {
963    if envelope.meta.request_id.is_empty()
964        || envelope.meta.request_id.len() > MAX_REQUEST_ID_BYTES
965        || envelope.meta.request_id.bytes().any(|byte| byte.is_ascii_control())
966    {
967        return Err(ClientError::InvalidRequestIdentity);
968    }
969    let payload = serde_json::to_vec(envelope).map_err(|_| ClientError::RequestCodec)?;
970    if payload.is_empty() || payload.len() > MAX_NATIVE_REQUEST_BYTES {
971        return Err(ClientError::RequestTooLarge);
972    }
973    let decoded: RequestEnvelope = serde_json::from_slice(&payload).map_err(|_| ClientError::RequestCodec)?;
974    if decoded != *envelope {
975        return Err(ClientError::RequestCodec);
976    }
977    Ok(payload)
978}
979
980fn build_tls_config(config: &CellTransportConfig) -> Result<rustls::ClientConfig, ClientError> {
981    let mut roots = rustls::RootCertStore::empty();
982    for certificate in config.trust.certificates_der() {
983        roots
984            .add(CertificateDer::from(certificate.clone()))
985            .map_err(|_| ClientError::Config(ClientConfigError::InvalidTrustMaterial))?;
986    }
987    let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
988    let mut tls = rustls::ClientConfig::builder_with_provider(provider)
989        .with_protocol_versions(&[&rustls::version::TLS13])
990        .map_err(|_| ClientError::Config(ClientConfigError::InvalidTrustMaterial))?
991        .with_root_certificates(roots)
992        .with_no_client_auth();
993    tls.alpn_protocols = vec![LODESTONE_API_ALPN.as_bytes().to_vec()];
994    Ok(tls)
995}
996
997fn validate_discovery(config: &CollectionSessionConfig, discovered: &DiscoverResult) -> Result<(), ClientError> {
998    let profile = config.profile.as_wire();
999    if discovered.collection_id != profile.expected_collection_id
1000        || discovered.principal_id != profile.expected_principal_id
1001        || discovered.schema_id != LODESTONE_API_SCHEMA_ID
1002        || discovered.schema_digest.value != LODESTONE_API_SPEC_SHA256
1003    {
1004        return Err(ClientError::ProfileIdentityMismatch);
1005    }
1006    let expected = LODESTONE_API_STREAM_CONTRACTS
1007        .first()
1008        .ok_or(ClientError::MissingCommandContract)?;
1009    if discovered.streams.len() != 1
1010        || discovered.streams.first().is_none_or(|stream| {
1011            stream.name != expected.name
1012                || stream.transport != expected.transport
1013                || stream.service != expected.service
1014                || stream.method != expected.method
1015                || stream.endpoint != profile.selected_stream_endpoint
1016                || stream.auth_metadata != expected.auth_metadata
1017                || stream.auth_scheme != expected.auth_scheme
1018                || stream.semantic_format_version != u32::from(expected.semantic_format_version)
1019                || stream.schema_digest.value != LODESTONE_API_SPEC_SHA256
1020        })
1021    {
1022        return Err(ClientError::ProfileStreamMismatch);
1023    }
1024    Ok(())
1025}
1026
1027fn validate_binding(
1028    config: &CollectionSessionConfig,
1029    binding: &CoreBinding,
1030    derived: bool,
1031    revision: &U64,
1032) -> Result<(), ClientError> {
1033    let profile = config.profile.as_wire();
1034    if !derived
1035        || binding.cluster_id != profile.expected_cluster_id
1036        || binding.collection_id != profile.expected_collection_id
1037        || binding.native_endpoints != profile.native_endpoints
1038        || binding.selected_stream_endpoint != profile.selected_stream_endpoint
1039        || binding.schema_id != profile.schema_id
1040        || binding.schema_digest != profile.schema_digest
1041        || binding
1042            .configuration_generation
1043            .value
1044            .parse::<u64>()
1045            .ok()
1046            .is_none_or(|generation| generation == 0)
1047        || revision.value.parse::<u64>().ok().is_none_or(|revision| revision == 0)
1048    {
1049        return Err(ClientError::ProfileIdentityMismatch);
1050    }
1051    Ok(())
1052}
1053
1054fn validate_cached_verification(
1055    config: &CollectionSessionConfig,
1056    verification: &ProfileVerification,
1057) -> Result<(), ClientError> {
1058    let profile = config.profile.as_wire();
1059    if verification.collection_id != profile.expected_collection_id
1060        || verification.principal_id != profile.expected_principal_id
1061        || verification.cluster_id != profile.expected_cluster_id
1062        || verification.schema_id != profile.schema_id
1063        || verification.schema_digest != profile.schema_digest
1064        || verification.native_endpoints != profile.native_endpoints
1065        || verification.selected_stream_endpoint != profile.selected_stream_endpoint
1066        || verification
1067            .configuration_generation
1068            .value
1069            .parse::<u64>()
1070            .ok()
1071            .is_none_or(|generation| generation == 0)
1072        || verification
1073            .binding_revision
1074            .value
1075            .parse::<u64>()
1076            .ok()
1077            .is_none_or(|revision| revision == 0)
1078    {
1079        return Err(ClientError::ProfileIdentityMismatch);
1080    }
1081    Ok(())
1082}
1083
1084fn resolve_index_name(request: &RequestBody, supplied: Option<&str>) -> Result<Option<String>, ClientError> {
1085    let scope = request_collection_index_ref(request)?;
1086    let inferred = match &scope {
1087        GeneratedCollectionIndexRef::Exact(name) => Some(name.clone()),
1088        GeneratedCollectionIndexRef::None | GeneratedCollectionIndexRef::Caller => None,
1089    };
1090    if let Some(supplied) = supplied {
1091        validate_index_name(supplied)?;
1092        if inferred.as_deref().is_some_and(|inferred| inferred != supplied) {
1093            return Err(ClientError::CollectionIndexRefMismatch);
1094        }
1095    }
1096    let resolved = supplied.map(str::to_owned).or(inferred);
1097    if let Some(name) = &resolved {
1098        validate_index_name(name)?;
1099    }
1100    match scope {
1101        GeneratedCollectionIndexRef::Caller | GeneratedCollectionIndexRef::Exact(_) if resolved.is_none() => {
1102            return Err(ClientError::CollectionIndexRefRequired);
1103        }
1104        GeneratedCollectionIndexRef::None if resolved.is_some() => return Err(ClientError::CollectionIndexRefMismatch),
1105        GeneratedCollectionIndexRef::None
1106        | GeneratedCollectionIndexRef::Caller
1107        | GeneratedCollectionIndexRef::Exact(_) => {}
1108    }
1109    Ok(resolved)
1110}
1111
1112const fn action_name(action: Action) -> &'static str {
1113    match action {
1114        Action::IdentityDiscovery => "identity_discovery",
1115        Action::DataRead => "data_read",
1116        Action::DataWrite => "data_write",
1117        Action::Append => "append",
1118        Action::ResolveAppend => "resolve_append",
1119        Action::IndexAdmin => "index_admin",
1120        Action::CatalogRead => "catalog_read",
1121        Action::TaskRead => "task_read",
1122        Action::SubscriptionRead => "subscription_read",
1123        Action::DiagnosticsRead => "diagnostics_read",
1124        Action::DiagnosticsCreate => "diagnostics_create",
1125        Action::BackupRead => "backup_read",
1126        Action::BackupAdmin => "backup_admin",
1127        Action::SecurityRead => "security_read",
1128        Action::SecurityAdmin => "security_admin",
1129        Action::AuditRead => "audit_read",
1130        Action::QuotaRead => "quota_read",
1131        Action::QuotaAdmin => "quota_admin",
1132        Action::BindingRead => "binding_read",
1133        Action::LifecycleAdmin => "lifecycle_admin",
1134        Action::AdmissionRead => "admission_read",
1135        Action::AdmissionAdmin => "admission_admin",
1136        Action::UsageRead => "usage_read",
1137        Action::OperationRead => "operation_read",
1138        Action::OperationCancel => "operation_cancel",
1139    }
1140}
1141
1142fn native_credential(material: &CredentialMaterial) -> Result<SensitiveBuffer, ClientError> {
1143    let identifier = parse_identity(&material.api_key_id().value)
1144        .ok_or(ClientError::Credential(CredentialProviderError::InvalidMaterial))?;
1145    let mut output = SensitiveBuffer::new();
1146    output.extend(identifier.to_string().as_bytes());
1147    output.extend(b":");
1148    output.extend(&base64_url_no_pad(material.secret().expose()));
1149    Ok(output)
1150}
1151
1152pub(crate) fn authorization_value(material: &CredentialMaterial) -> Result<SensitiveBuffer, ClientError> {
1153    let native = native_credential(material)?;
1154    let mut output = SensitiveBuffer::new();
1155    output.extend(b"ApiKey ");
1156    output.extend(&base64_standard(native.as_slice()));
1157    Ok(output)
1158}
1159
1160fn base64_url_no_pad(bytes: &[u8]) -> Vec<u8> {
1161    base64_encode(
1162        bytes,
1163        b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_",
1164        false,
1165    )
1166}
1167
1168fn base64_standard(bytes: &[u8]) -> Vec<u8> {
1169    base64_encode(
1170        bytes,
1171        b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",
1172        true,
1173    )
1174}
1175
1176/// Converts exact UTF-8 text into the authoritative Lodestone API raw JSON wire
1177/// leaf without parsing or normalizing the text.
1178pub fn raw_json_text(text: &str) -> Result<RawJson, ClientError> {
1179    let bytes_base64 = String::from_utf8(base64_standard(text.as_bytes())).map_err(|_| ClientError::RequestCodec)?;
1180    Ok(RawJson { bytes_base64 })
1181}
1182
1183fn base64_encode(bytes: &[u8], alphabet: &[u8; 64], padded: bool) -> Vec<u8> {
1184    let mut output = Vec::with_capacity(bytes.len().div_ceil(3).saturating_mul(4));
1185    for chunk in bytes.chunks(3) {
1186        let first = chunk[0];
1187        let second = chunk.get(1).copied().unwrap_or(0);
1188        let third = chunk.get(2).copied().unwrap_or(0);
1189        output.push(alphabet[usize::from(first >> 2)]);
1190        output.push(alphabet[usize::from(((first & 0x03) << 4) | (second >> 4))]);
1191        if chunk.len() > 1 {
1192            output.push(alphabet[usize::from(((second & 0x0f) << 2) | (third >> 6))]);
1193        } else if padded {
1194            output.push(b'=');
1195        }
1196        if chunk.len() > 2 {
1197            output.push(alphabet[usize::from(third & 0x3f)]);
1198        } else if padded {
1199            output.push(b'=');
1200        }
1201    }
1202    output
1203}
1204
1205fn random_identity() -> Result<[u8; 16], ClientError> {
1206    let mut output = [0; 16];
1207    getrandom::fill(&mut output).map_err(|_| ClientError::RandomUnavailable)?;
1208    if output == [0; 16] {
1209        return Err(ClientError::RandomUnavailable);
1210    }
1211    Ok(output)
1212}
1213
1214fn normalized_timeout(timeout: Duration) -> Result<Duration, ClientError> {
1215    if timeout.is_zero() || timeout > MAX_CALL_TIMEOUT {
1216        return Err(ClientError::InvalidCallOptions);
1217    }
1218    let mut millis = timeout.as_millis();
1219    if !timeout.subsec_nanos().is_multiple_of(1_000_000) {
1220        millis = millis.checked_add(1).ok_or(ClientError::InvalidCallOptions)?;
1221    }
1222    let millis = u64::try_from(millis).map_err(|_| ClientError::InvalidCallOptions)?;
1223    let normalized = Duration::from_millis(millis);
1224    if normalized > MAX_CALL_TIMEOUT {
1225        return Err(ClientError::InvalidCallOptions);
1226    }
1227    Ok(normalized)
1228}
1229
1230fn duration_millis_ceil(duration: Duration) -> Result<u64, ClientError> {
1231    let mut millis = duration.as_millis();
1232    if !duration.subsec_nanos().is_multiple_of(1_000_000) {
1233        millis = millis.checked_add(1).ok_or(ClientError::ClockUnavailable)?;
1234    }
1235    u64::try_from(millis).map_err(|_| ClientError::ClockUnavailable)
1236}
1237
1238pub(crate) fn validate_index_name(value: &str) -> Result<(), ClientError> {
1239    if value.is_empty()
1240        || value.len() > 255
1241        || value
1242            .bytes()
1243            .any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace())
1244    {
1245        return Err(ClientError::InvalidCollectionIndexRef);
1246    }
1247    Ok(())
1248}
1249
1250fn lower_hex(bytes: &[u8]) -> String {
1251    const HEX: &[u8; 16] = b"0123456789abcdef";
1252    let mut output = String::with_capacity(bytes.len().saturating_mul(2));
1253    for byte in bytes {
1254        output.push(char::from(HEX[usize::from(byte >> 4)]));
1255        output.push(char::from(HEX[usize::from(byte & 0x0f)]));
1256    }
1257    output
1258}
1259
1260pub(crate) struct SensitiveBuffer(Vec<u8>);
1261
1262impl SensitiveBuffer {
1263    fn new() -> Self {
1264        Self(Vec::new())
1265    }
1266
1267    fn extend(&mut self, bytes: &[u8]) {
1268        self.0.extend_from_slice(bytes);
1269    }
1270
1271    fn extend_u32(&mut self, value: usize) -> Result<(), ClientError> {
1272        let value = u32::try_from(value).map_err(|_| ClientError::RequestTooLarge)?;
1273        self.extend(&value.to_be_bytes());
1274        Ok(())
1275    }
1276
1277    fn is_empty(&self) -> bool {
1278        self.0.is_empty()
1279    }
1280
1281    fn len(&self) -> usize {
1282        self.0.len()
1283    }
1284
1285    pub(crate) fn as_slice(&self) -> &[u8] {
1286        &self.0
1287    }
1288}
1289
1290impl Drop for SensitiveBuffer {
1291    fn drop(&mut self) {
1292        self.0.fill(0);
1293        std::hint::black_box(&mut self.0);
1294    }
1295}
1296
1297fn started_disposition(started: &AtomicBool) -> SubmissionDisposition {
1298    if started.load(Ordering::Acquire) {
1299        SubmissionDisposition::Indeterminate
1300    } else {
1301        SubmissionDisposition::NotSubmitted
1302    }
1303}
1304
1305#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1306pub enum SubmissionDisposition {
1307    NotSubmitted,
1308    Submitted,
1309    Resolved,
1310    KnownRetired,
1311    Indeterminate,
1312}
1313
1314#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1315pub enum TransportErrorKind {
1316    PoolClosed,
1317    Connect,
1318    Tls,
1319    Write,
1320    Read,
1321    Deadline,
1322    Cancelled,
1323}
1324
1325#[derive(Debug)]
1326pub enum ClientError {
1327    Config(ClientConfigError),
1328    Credential(CredentialProviderError),
1329    InvalidCallOptions,
1330    InvalidEndpoint,
1331    InvalidCollectionIndexRef,
1332    InvalidStateIndex,
1333    InvalidStateActionId,
1334    InvalidStateKey,
1335    InvalidStateBatch,
1336    UnexpectedStateResult,
1337    CollectionIndexRefRequired,
1338    CollectionIndexRefMismatch,
1339    ProfileNotVerified,
1340    ProfileIdentityMismatch,
1341    ProfileStreamMismatch,
1342    MissingCommandContract,
1343    RequestCodec,
1344    RequestTooLarge,
1345    InvalidRequestIdentity,
1346    ResponseCodec,
1347    ResponseTooLarge,
1348    NonCanonicalResponse,
1349    ResponseIdentityMismatch,
1350    ResponseCommandMismatch,
1351    InvalidErrorShape,
1352    RandomUnavailable,
1353    ClockUnavailable,
1354    InternalStateUnavailable,
1355    Transport {
1356        kind: TransportErrorKind,
1357        disposition: SubmissionDisposition,
1358    },
1359    Server(ErrorBody),
1360}
1361
1362impl ClientError {
1363    #[must_use]
1364    pub fn submission_disposition(&self) -> SubmissionDisposition {
1365        match self {
1366            Self::Transport { disposition, .. } => *disposition,
1367            Self::Server(error) => match server_error_disposition(error) {
1368                Some(disposition) => disposition,
1369                None => SubmissionDisposition::Indeterminate,
1370            },
1371            Self::UnexpectedStateResult
1372            | Self::ResponseCodec
1373            | Self::ResponseTooLarge
1374            | Self::NonCanonicalResponse
1375            | Self::ResponseIdentityMismatch
1376            | Self::ResponseCommandMismatch
1377            | Self::InvalidErrorShape => SubmissionDisposition::Indeterminate,
1378            _ => SubmissionDisposition::NotSubmitted,
1379        }
1380    }
1381
1382    pub(crate) fn is_deadline_exceeded(&self) -> bool {
1383        matches!(
1384            self,
1385            Self::Transport {
1386                kind: TransportErrorKind::Deadline,
1387                ..
1388            } | Self::Server(ErrorBody {
1389                code: lodestone_wire::ErrorCode::DeadlineExceeded,
1390                ..
1391            })
1392        )
1393    }
1394}
1395
1396fn server_error_disposition(error: &ErrorBody) -> Option<SubmissionDisposition> {
1397    let contract = LODESTONE_API_ERROR_OUTCOMES
1398        .iter()
1399        .find(|contract| contract.code == error.code)?;
1400    let value = if error.submitted {
1401        contract.when_submitted
1402    } else {
1403        contract.when_not_submitted
1404    }?;
1405    match value {
1406        "not_submitted" => Some(SubmissionDisposition::NotSubmitted),
1407        "submitted" => Some(SubmissionDisposition::Submitted),
1408        "resolved" => Some(SubmissionDisposition::Resolved),
1409        "known_retired" => Some(SubmissionDisposition::KnownRetired),
1410        "indeterminate" => Some(SubmissionDisposition::Indeterminate),
1411        _ => None,
1412    }
1413}
1414
1415impl From<ClientConfigError> for ClientError {
1416    fn from(error: ClientConfigError) -> Self {
1417        Self::Config(error)
1418    }
1419}
1420
1421impl Display for ClientError {
1422    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
1423        formatter.write_str(match self {
1424            Self::Config(_) => "Core client configuration is invalid",
1425            Self::Credential(_) => "Core credential material is unavailable or mismatched",
1426            Self::InvalidCallOptions => "Core call options are invalid",
1427            Self::InvalidEndpoint => "Core endpoint is invalid",
1428            Self::InvalidCollectionIndexRef => "Core request index scope is invalid",
1429            Self::InvalidStateIndex => "Lodestone state index identity is invalid",
1430            Self::InvalidStateActionId => "Lodestone state action identity is invalid",
1431            Self::InvalidStateKey => "Lodestone state key must contain 1 to 254 bytes",
1432            Self::InvalidStateBatch => "Lodestone state key batch is empty or exceeds its bound",
1433            Self::UnexpectedStateResult => "Lodestone state result differs from the requested operation",
1434            Self::CollectionIndexRefRequired => "Core request requires an exact index scope",
1435            Self::CollectionIndexRefMismatch => "Core request and admission index scopes differ",
1436            Self::ProfileNotVerified => "Core mutation is refused until discovery and binding are verified",
1437            Self::ProfileIdentityMismatch => "Core discovery or binding differs from the connection profile",
1438            Self::ProfileStreamMismatch => "Core selected stream differs from the connection profile",
1439            Self::MissingCommandContract => "generated Core command metadata is incomplete",
1440            Self::RequestCodec => "Core request is not a canonical current Lodestone API value",
1441            Self::RequestTooLarge => "Core request exceeds its generated byte bound",
1442            Self::InvalidRequestIdentity => "Core request identity is invalid",
1443            Self::ResponseCodec => "Core response is malformed or unsupported",
1444            Self::ResponseTooLarge => "Core response exceeds its generated byte bound",
1445            Self::NonCanonicalResponse => "Core response encoding is noncanonical",
1446            Self::ResponseIdentityMismatch => "Core response changed the request identity or version",
1447            Self::ResponseCommandMismatch => "Core response returned another command result",
1448            Self::InvalidErrorShape => "Core error detail does not match its generated code",
1449            Self::RandomUnavailable => "secure request identity generation is unavailable",
1450            Self::ClockUnavailable => "absolute Core request deadline cannot be calculated",
1451            Self::InternalStateUnavailable => "Core client verification state is unavailable",
1452            Self::Transport { .. } => "Core transport failed",
1453            Self::Server(_) => "Core returned a typed request error",
1454        })
1455    }
1456}
1457
1458impl Error for ClientError {}
1459
1460#[cfg(test)]
1461mod tests {
1462    use std::error::Error;
1463
1464    use lodestone_wire::{GetIndexRequest, Identity, OpenPitRequest, Resource, U64};
1465
1466    use super::*;
1467    use crate::SecretBytes;
1468
1469    #[test]
1470    fn response_failures_preserve_indeterminate_submission() {
1471        for error in [
1472            ClientError::UnexpectedStateResult,
1473            ClientError::ResponseCodec,
1474            ClientError::ResponseTooLarge,
1475            ClientError::NonCanonicalResponse,
1476            ClientError::ResponseIdentityMismatch,
1477            ClientError::ResponseCommandMismatch,
1478            ClientError::InvalidErrorShape,
1479        ] {
1480            assert_eq!(error.submission_disposition(), SubmissionDisposition::Indeterminate);
1481        }
1482        assert_eq!(
1483            ClientError::RequestCodec.submission_disposition(),
1484            SubmissionDisposition::NotSubmitted
1485        );
1486    }
1487
1488    fn cache_key(collection: u128, principal: u128, credential: u128) -> VerifiedSessionCacheKey {
1489        VerifiedSessionCacheKey {
1490            collection_id: format!("{collection:032x}"),
1491            principal_id: format!("{principal:032x}"),
1492            profile_generation: "1".to_owned(),
1493            credential_id: format!("{credential:032x}"),
1494            credential_generation: "1".to_owned(),
1495            secret_reference: format!("memory://session/{collection}/{principal}/{credential}"),
1496        }
1497    }
1498
1499    fn verification(collection: u128, principal: u128) -> ProfileVerification {
1500        ProfileVerification {
1501            artifact_version: "development".to_owned(),
1502            principal_id: Identity {
1503                value: format!("{principal:032x}"),
1504            },
1505            collection_id: Identity {
1506                value: format!("{collection:032x}"),
1507            },
1508            policy_revision: U64 { value: "1".to_owned() },
1509            cluster_id: Identity {
1510                value: "00000000000000000000000000000001".to_owned(),
1511            },
1512            configuration_generation: U64 { value: "1".to_owned() },
1513            binding_revision: U64 { value: "1".to_owned() },
1514            schema_id: LODESTONE_API_SCHEMA_ID.to_owned(),
1515            schema_digest: lodestone_wire::Digest {
1516                value: LODESTONE_API_SPEC_SHA256.to_owned(),
1517            },
1518            native_endpoints: vec!["lodestone+tls://core.example:8443".to_owned()],
1519            selected_stream_endpoint: "https://core.example:9443".to_owned(),
1520        }
1521    }
1522
1523    #[test]
1524    fn mutation_retry_stops_at_the_submission_boundary() {
1525        assert!(retry_admitted(true, true, SubmissionDisposition::NotSubmitted));
1526        assert!(!retry_admitted(true, true, SubmissionDisposition::Submitted));
1527        assert!(!retry_admitted(true, true, SubmissionDisposition::Indeterminate));
1528        assert!(!retry_admitted(true, false, SubmissionDisposition::NotSubmitted));
1529        assert!(retry_admitted(false, true, SubmissionDisposition::Indeterminate));
1530    }
1531
1532    #[test]
1533    fn one_deadline_survives_connect_state_and_resolution_clones() -> Result<(), Box<dyn Error>> {
1534        let options = CallOptions::new(Duration::from_secs(5))?;
1535        let absolute = options.absolute_deadline_unix_millis()?;
1536        let local = options.local_deadline()?;
1537
1538        // These clones stand for discovery, binding, prepare, submit, and
1539        // resolution. None may derive a fresh duration from the current time.
1540        for stage in [
1541            options.clone(),
1542            options.clone(),
1543            options.clone(),
1544            options.clone(),
1545            options.for_resolution(),
1546        ] {
1547            assert_eq!(stage.absolute_deadline_unix_millis()?, absolute);
1548            assert_eq!(stage.local_deadline()?, local);
1549        }
1550
1551        let resolution = options.for_resolution();
1552        options.cancellation().cancel();
1553        assert!(options.cancellation().is_cancelled());
1554        assert!(!resolution.cancellation().is_cancelled());
1555        assert_eq!(resolution.absolute_deadline_unix_millis()?, absolute);
1556        Ok(())
1557    }
1558
1559    #[test]
1560    fn caller_supplied_absolute_deadline_is_preserved_exactly() -> Result<(), Box<dyn Error>> {
1561        let now = SystemTime::now().duration_since(UNIX_EPOCH)?;
1562        let now_millis = u64::try_from(now.as_millis())?;
1563        let absolute = now_millis.checked_add(5_000).ok_or("test deadline overflow")?;
1564        let options = CallOptions::from_absolute_deadline_unix_millis(absolute)?;
1565        assert_eq!(options.absolute_deadline_unix_millis()?, absolute);
1566
1567        let too_far = now_millis
1568            .checked_add(u64::try_from(MAX_CALL_TIMEOUT.as_millis())?)
1569            .and_then(|value| value.checked_add(1_000))
1570            .ok_or("test deadline overflow")?;
1571        assert!(matches!(
1572            CallOptions::from_absolute_deadline_unix_millis(too_far),
1573            Err(ClientError::InvalidCallOptions)
1574        ));
1575        assert_eq!(normalized_timeout(Duration::from_nanos(1))?, Duration::from_millis(1));
1576        Ok(())
1577    }
1578
1579    #[test]
1580    fn private_api_key_encodings_match_the_core_admission_contract() -> Result<(), Box<dyn Error>> {
1581        let material = CredentialMaterial::new(
1582            Identity {
1583                value: "00000000000000000000000000000001".to_owned(),
1584            },
1585            U64 { value: "1".to_owned() },
1586            SecretBytes::from_array([0; 32]),
1587        )?;
1588        assert_eq!(
1589            native_credential(&material)?.as_slice(),
1590            b"1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
1591        );
1592        assert_eq!(
1593            authorization_value(&material)?.as_slice(),
1594            b"ApiKey MTpBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB"
1595        );
1596        Ok(())
1597    }
1598
1599    #[test]
1600    fn typed_refusal_discards_the_connection_before_a_dependent_follow_up() {
1601        let refusal = Err(ClientError::Server(ErrorBody {
1602            code: lodestone_wire::ErrorCode::PermissionDenied,
1603            message: "denied".to_owned(),
1604            retry_safe: false,
1605            submitted: false,
1606            details: None,
1607        }));
1608        assert!(!response_keeps_connection(&refusal));
1609        let failure = Err(ClientError::Transport {
1610            kind: TransportErrorKind::Read,
1611            disposition: SubmissionDisposition::Indeterminate,
1612        });
1613        assert!(!response_keeps_connection(&failure));
1614    }
1615
1616    #[test]
1617    fn verified_session_cache_is_bounded_and_never_crosses_principals() {
1618        let metrics = CellTransportMetrics::default();
1619        let mut cache = VerifiedSessionCache::default();
1620        let first = cache_key(1, 10, 100);
1621        let other_principal = cache_key(1, 11, 100);
1622        assert_ne!(first, other_principal);
1623        cache.insert(first.clone(), verification(1, 10), &metrics);
1624        assert!(cache.get(&other_principal, &metrics).is_none());
1625        assert_eq!(cache.get(&first, &metrics), Some(verification(1, 10)));
1626
1627        for offset in 0..=MAX_VERIFIED_SESSION_METADATA {
1628            let identity = u128::try_from(offset).unwrap_or_default().saturating_add(1_000);
1629            cache.insert(
1630                cache_key(identity, identity, identity),
1631                verification(identity, identity),
1632                &metrics,
1633            );
1634        }
1635        assert_eq!(cache.entries.len(), MAX_VERIFIED_SESSION_METADATA);
1636        assert!(metrics.session_cache_evictions.load(Ordering::Relaxed) > 0);
1637        assert_eq!(
1638            metrics.session_cache_entries.load(Ordering::Relaxed),
1639            MAX_VERIFIED_SESSION_METADATA
1640        );
1641        assert_eq!(metrics.session_cache_hits.load(Ordering::Relaxed), 1);
1642        assert_eq!(metrics.session_cache_misses.load(Ordering::Relaxed), 1);
1643    }
1644
1645    #[test]
1646    fn generated_multi_index_scope_requires_a_nonempty_distinct_set() {
1647        fn index(name: &str, incarnation: &str) -> Resource {
1648            Resource {
1649                kind: "index".to_owned(),
1650                name: name.to_owned(),
1651                incarnation: Identity {
1652                    value: incarnation.to_owned(),
1653                },
1654            }
1655        }
1656
1657        fn request(indexes: Vec<Resource>) -> RequestBody {
1658            RequestBody::OpenPit(OpenPitRequest {
1659                indexes,
1660                keep_alive_millis: U64 {
1661                    value: "1000".to_owned(),
1662                },
1663            })
1664        }
1665
1666        let first = index("orders", "00000000000000000000000000000001");
1667        let second = index("events", "00000000000000000000000000000002");
1668        assert!(matches!(
1669            resolve_index_name(&request(Vec::new()), None),
1670            Err(ClientError::CollectionIndexRefRequired)
1671        ));
1672        assert!(matches!(
1673            resolve_index_name(&request(vec![first.clone(), second.clone()]), None),
1674            Ok(None)
1675        ));
1676        assert!(matches!(
1677            resolve_index_name(&request(vec![first.clone(), first.clone()]), None),
1678            Err(ClientError::CollectionIndexRefMismatch)
1679        ));
1680        assert!(matches!(
1681            resolve_index_name(&request(vec![second.clone(), first.clone(), first]), None),
1682            Err(ClientError::CollectionIndexRefMismatch)
1683        ));
1684    }
1685
1686    #[test]
1687    fn generated_get_index_is_collection_scoped() {
1688        let request = RequestBody::GetIndex(GetIndexRequest {
1689            name: "events".to_owned(),
1690            expected_incarnation: None,
1691        });
1692
1693        assert!(matches!(resolve_index_name(&request, None), Ok(None)));
1694        assert!(matches!(
1695            resolve_index_name(&request, Some("events")),
1696            Err(ClientError::CollectionIndexRefMismatch)
1697        ));
1698    }
1699}