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 complete, validated typed refusal is Collection-local and leaves
807        // the shared byte stream synchronized. Reuse that connection; only
808        // transport/framing/identity failures discard it.
809        if response_keeps_connection(&result) {
810            let mut pool = self.inner.transport.inner.pool.lock().await;
811            if pool.len() < self.pool_config().maximum_idle_connections {
812                pool.push(connection);
813                self.inner
814                    .transport
815                    .inner
816                    .metrics
817                    .idle_connections
818                    .store(pool.len(), Ordering::Relaxed);
819            }
820        }
821        result
822    }
823
824    async fn connect_native(&self, endpoint_index: usize) -> Result<NativeConnection, ClientError> {
825        let endpoint = self
826            .inner
827            .transport
828            .inner
829            .config
830            .native_endpoints
831            .get(endpoint_index)
832            .ok_or(ClientError::InvalidEndpoint)?;
833        let stream = tokio::time::timeout(
834            self.pool_config().connect_timeout,
835            TcpStream::connect((endpoint.host(), endpoint.port())),
836        )
837        .await
838        .map_err(|_| ClientError::Transport {
839            kind: TransportErrorKind::Connect,
840            disposition: SubmissionDisposition::NotSubmitted,
841        })?
842        .map_err(|_| ClientError::Transport {
843            kind: TransportErrorKind::Connect,
844            disposition: SubmissionDisposition::NotSubmitted,
845        })?;
846        let server_name = ServerName::try_from(self.inner.transport.inner.config.identity.tls_server_name().to_owned())
847            .map_err(|_| ClientError::Config(ClientConfigError::InvalidServerName))?;
848        let connector = TlsConnector::from(Arc::clone(&self.inner.transport.inner.tls));
849        let stream = tokio::time::timeout(
850            self.pool_config().connect_timeout,
851            connector.connect(server_name, stream),
852        )
853        .await
854        .map_err(|_| ClientError::Transport {
855            kind: TransportErrorKind::Tls,
856            disposition: SubmissionDisposition::NotSubmitted,
857        })?
858        .map_err(|_| ClientError::Transport {
859            kind: TransportErrorKind::Tls,
860            disposition: SubmissionDisposition::NotSubmitted,
861        })?;
862        if stream.get_ref().1.alpn_protocol() != Some(LODESTONE_API_ALPN.as_bytes()) {
863            return Err(ClientError::Transport {
864                kind: TransportErrorKind::Tls,
865                disposition: SubmissionDisposition::NotSubmitted,
866            });
867        }
868        self.inner
869            .transport
870            .inner
871            .metrics
872            .live_connections
873            .fetch_add(1, Ordering::Relaxed);
874        Ok(NativeConnection {
875            stream,
876            metrics: Arc::clone(&self.inner.transport.inner.metrics),
877        })
878    }
879
880    pub(crate) async fn credential(&self) -> Result<CredentialMaterial, ClientError> {
881        let profile = self.inner.config.profile.as_wire();
882        self.inner
883            .transport
884            .inner
885            .metrics
886            .credential_fetches
887            .fetch_add(1, Ordering::Relaxed);
888        let material = self
889            .inner
890            .config
891            .credentials
892            .provide(&profile.secret_reference)
893            .await
894            .map_err(ClientError::Credential)?;
895        if material.api_key_id() != &profile.credential_id || material.generation() != &profile.credential_generation {
896            return Err(ClientError::Credential(CredentialProviderError::InvalidMaterial));
897        }
898        Ok(material)
899    }
900
901    pub(crate) fn transport_config(&self) -> &CellTransportConfig {
902        &self.inner.transport.inner.config
903    }
904
905    pub(crate) fn pool_config(&self) -> PoolConfig {
906        self.inner.transport.pool_config()
907    }
908}
909
910fn response_keeps_connection(result: &Result<ResultBody, ClientError>) -> bool {
911    matches!(result, Ok(_) | Err(ClientError::Server(_)))
912}
913
914const fn default_workload_class(action: Action) -> WorkloadClass {
915    match action {
916        Action::DataRead | Action::SubscriptionRead => WorkloadClass::InteractiveSearch,
917        Action::DataWrite | Action::Append | Action::ResolveAppend => WorkloadClass::StreamingIngest,
918        Action::IdentityDiscovery
919        | Action::IndexAdmin
920        | Action::CatalogRead
921        | Action::TaskRead
922        | Action::DiagnosticsRead
923        | Action::DiagnosticsCreate
924        | Action::BackupRead
925        | Action::BackupAdmin
926        | Action::SecurityRead
927        | Action::SecurityAdmin
928        | Action::AuditRead
929        | Action::QuotaRead
930        | Action::QuotaAdmin
931        | Action::BindingRead
932        | Action::LifecycleAdmin
933        | Action::AdmissionRead
934        | Action::AdmissionAdmin
935        | Action::UsageRead
936        | Action::OperationRead
937        | Action::OperationCancel => WorkloadClass::BatchAnalytics,
938    }
939}
940
941const fn retry_admitted(mutation: bool, pre_submit_retry_safe: bool, disposition: SubmissionDisposition) -> bool {
942    pre_submit_retry_safe && (!mutation || matches!(disposition, SubmissionDisposition::NotSubmitted))
943}
944
945#[derive(serde::Serialize)]
946struct PublicSession<'a> {
947    format_version: u16,
948    api_key_credential: &'a [u8],
949    collection_id: &'a str,
950    request_id: &'a str,
951    action: &'static str,
952    workload_class: WorkloadClass,
953    #[serde(skip_serializing_if = "Option::is_none")]
954    index_name: Option<&'a str>,
955    request_correlation_id: [u8; 16],
956}
957
958fn canonical_request(envelope: &RequestEnvelope) -> Result<Vec<u8>, ClientError> {
959    if envelope.meta.request_id.is_empty()
960        || envelope.meta.request_id.len() > MAX_REQUEST_ID_BYTES
961        || envelope.meta.request_id.bytes().any(|byte| byte.is_ascii_control())
962    {
963        return Err(ClientError::InvalidRequestIdentity);
964    }
965    let payload = serde_json::to_vec(envelope).map_err(|_| ClientError::RequestCodec)?;
966    if payload.is_empty() || payload.len() > MAX_NATIVE_REQUEST_BYTES {
967        return Err(ClientError::RequestTooLarge);
968    }
969    let decoded: RequestEnvelope = serde_json::from_slice(&payload).map_err(|_| ClientError::RequestCodec)?;
970    if decoded != *envelope {
971        return Err(ClientError::RequestCodec);
972    }
973    Ok(payload)
974}
975
976fn build_tls_config(config: &CellTransportConfig) -> Result<rustls::ClientConfig, ClientError> {
977    let mut roots = rustls::RootCertStore::empty();
978    for certificate in config.trust.certificates_der() {
979        roots
980            .add(CertificateDer::from(certificate.clone()))
981            .map_err(|_| ClientError::Config(ClientConfigError::InvalidTrustMaterial))?;
982    }
983    let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
984    let mut tls = rustls::ClientConfig::builder_with_provider(provider)
985        .with_protocol_versions(&[&rustls::version::TLS13])
986        .map_err(|_| ClientError::Config(ClientConfigError::InvalidTrustMaterial))?
987        .with_root_certificates(roots)
988        .with_no_client_auth();
989    tls.alpn_protocols = vec![LODESTONE_API_ALPN.as_bytes().to_vec()];
990    Ok(tls)
991}
992
993fn validate_discovery(config: &CollectionSessionConfig, discovered: &DiscoverResult) -> Result<(), ClientError> {
994    let profile = config.profile.as_wire();
995    if discovered.collection_id != profile.expected_collection_id
996        || discovered.principal_id != profile.expected_principal_id
997        || discovered.schema_id != LODESTONE_API_SCHEMA_ID
998        || discovered.schema_digest.value != LODESTONE_API_SPEC_SHA256
999    {
1000        return Err(ClientError::ProfileIdentityMismatch);
1001    }
1002    let expected = LODESTONE_API_STREAM_CONTRACTS
1003        .first()
1004        .ok_or(ClientError::MissingCommandContract)?;
1005    if discovered.streams.len() != 1
1006        || discovered.streams.first().is_none_or(|stream| {
1007            stream.name != expected.name
1008                || stream.transport != expected.transport
1009                || stream.service != expected.service
1010                || stream.method != expected.method
1011                || stream.endpoint != profile.selected_stream_endpoint
1012                || stream.auth_metadata != expected.auth_metadata
1013                || stream.auth_scheme != expected.auth_scheme
1014                || stream.semantic_format_version != u32::from(expected.semantic_format_version)
1015                || stream.schema_digest.value != LODESTONE_API_SPEC_SHA256
1016        })
1017    {
1018        return Err(ClientError::ProfileStreamMismatch);
1019    }
1020    Ok(())
1021}
1022
1023fn validate_binding(
1024    config: &CollectionSessionConfig,
1025    binding: &CoreBinding,
1026    derived: bool,
1027    revision: &U64,
1028) -> Result<(), ClientError> {
1029    let profile = config.profile.as_wire();
1030    if !derived
1031        || binding.cluster_id != profile.expected_cluster_id
1032        || binding.collection_id != profile.expected_collection_id
1033        || binding.native_endpoints != profile.native_endpoints
1034        || binding.selected_stream_endpoint != profile.selected_stream_endpoint
1035        || binding.schema_id != profile.schema_id
1036        || binding.schema_digest != profile.schema_digest
1037        || binding
1038            .configuration_generation
1039            .value
1040            .parse::<u64>()
1041            .ok()
1042            .is_none_or(|generation| generation == 0)
1043        || revision.value.parse::<u64>().ok().is_none_or(|revision| revision == 0)
1044    {
1045        return Err(ClientError::ProfileIdentityMismatch);
1046    }
1047    Ok(())
1048}
1049
1050fn validate_cached_verification(
1051    config: &CollectionSessionConfig,
1052    verification: &ProfileVerification,
1053) -> Result<(), ClientError> {
1054    let profile = config.profile.as_wire();
1055    if verification.collection_id != profile.expected_collection_id
1056        || verification.principal_id != profile.expected_principal_id
1057        || verification.cluster_id != profile.expected_cluster_id
1058        || verification.schema_id != profile.schema_id
1059        || verification.schema_digest != profile.schema_digest
1060        || verification.native_endpoints != profile.native_endpoints
1061        || verification.selected_stream_endpoint != profile.selected_stream_endpoint
1062        || verification
1063            .configuration_generation
1064            .value
1065            .parse::<u64>()
1066            .ok()
1067            .is_none_or(|generation| generation == 0)
1068        || verification
1069            .binding_revision
1070            .value
1071            .parse::<u64>()
1072            .ok()
1073            .is_none_or(|revision| revision == 0)
1074    {
1075        return Err(ClientError::ProfileIdentityMismatch);
1076    }
1077    Ok(())
1078}
1079
1080fn resolve_index_name(request: &RequestBody, supplied: Option<&str>) -> Result<Option<String>, ClientError> {
1081    let scope = request_collection_index_ref(request)?;
1082    let inferred = match &scope {
1083        GeneratedCollectionIndexRef::Exact(name) => Some(name.clone()),
1084        GeneratedCollectionIndexRef::None | GeneratedCollectionIndexRef::Caller => None,
1085    };
1086    if let Some(supplied) = supplied {
1087        validate_index_name(supplied)?;
1088        if inferred.as_deref().is_some_and(|inferred| inferred != supplied) {
1089            return Err(ClientError::CollectionIndexRefMismatch);
1090        }
1091    }
1092    let resolved = supplied.map(str::to_owned).or(inferred);
1093    if let Some(name) = &resolved {
1094        validate_index_name(name)?;
1095    }
1096    match scope {
1097        GeneratedCollectionIndexRef::Caller | GeneratedCollectionIndexRef::Exact(_) if resolved.is_none() => {
1098            return Err(ClientError::CollectionIndexRefRequired);
1099        }
1100        GeneratedCollectionIndexRef::None if resolved.is_some() => return Err(ClientError::CollectionIndexRefMismatch),
1101        GeneratedCollectionIndexRef::None
1102        | GeneratedCollectionIndexRef::Caller
1103        | GeneratedCollectionIndexRef::Exact(_) => {}
1104    }
1105    Ok(resolved)
1106}
1107
1108const fn action_name(action: Action) -> &'static str {
1109    match action {
1110        Action::IdentityDiscovery => "identity_discovery",
1111        Action::DataRead => "data_read",
1112        Action::DataWrite => "data_write",
1113        Action::Append => "append",
1114        Action::ResolveAppend => "resolve_append",
1115        Action::IndexAdmin => "index_admin",
1116        Action::CatalogRead => "catalog_read",
1117        Action::TaskRead => "task_read",
1118        Action::SubscriptionRead => "subscription_read",
1119        Action::DiagnosticsRead => "diagnostics_read",
1120        Action::DiagnosticsCreate => "diagnostics_create",
1121        Action::BackupRead => "backup_read",
1122        Action::BackupAdmin => "backup_admin",
1123        Action::SecurityRead => "security_read",
1124        Action::SecurityAdmin => "security_admin",
1125        Action::AuditRead => "audit_read",
1126        Action::QuotaRead => "quota_read",
1127        Action::QuotaAdmin => "quota_admin",
1128        Action::BindingRead => "binding_read",
1129        Action::LifecycleAdmin => "lifecycle_admin",
1130        Action::AdmissionRead => "admission_read",
1131        Action::AdmissionAdmin => "admission_admin",
1132        Action::UsageRead => "usage_read",
1133        Action::OperationRead => "operation_read",
1134        Action::OperationCancel => "operation_cancel",
1135    }
1136}
1137
1138fn native_credential(material: &CredentialMaterial) -> Result<SensitiveBuffer, ClientError> {
1139    let identifier = parse_identity(&material.api_key_id().value)
1140        .ok_or(ClientError::Credential(CredentialProviderError::InvalidMaterial))?;
1141    let mut output = SensitiveBuffer::new();
1142    output.extend(identifier.to_string().as_bytes());
1143    output.extend(b":");
1144    output.extend(&base64_url_no_pad(material.secret().expose()));
1145    Ok(output)
1146}
1147
1148pub(crate) fn authorization_value(material: &CredentialMaterial) -> Result<SensitiveBuffer, ClientError> {
1149    let native = native_credential(material)?;
1150    let mut output = SensitiveBuffer::new();
1151    output.extend(b"ApiKey ");
1152    output.extend(&base64_standard(native.as_slice()));
1153    Ok(output)
1154}
1155
1156fn base64_url_no_pad(bytes: &[u8]) -> Vec<u8> {
1157    base64_encode(
1158        bytes,
1159        b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_",
1160        false,
1161    )
1162}
1163
1164fn base64_standard(bytes: &[u8]) -> Vec<u8> {
1165    base64_encode(
1166        bytes,
1167        b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",
1168        true,
1169    )
1170}
1171
1172/// Converts exact UTF-8 text into the authoritative Lodestone API raw JSON wire
1173/// leaf without parsing or normalizing the text.
1174pub fn raw_json_text(text: &str) -> Result<RawJson, ClientError> {
1175    let bytes_base64 = String::from_utf8(base64_standard(text.as_bytes())).map_err(|_| ClientError::RequestCodec)?;
1176    Ok(RawJson { bytes_base64 })
1177}
1178
1179fn base64_encode(bytes: &[u8], alphabet: &[u8; 64], padded: bool) -> Vec<u8> {
1180    let mut output = Vec::with_capacity(bytes.len().div_ceil(3).saturating_mul(4));
1181    for chunk in bytes.chunks(3) {
1182        let first = chunk[0];
1183        let second = chunk.get(1).copied().unwrap_or(0);
1184        let third = chunk.get(2).copied().unwrap_or(0);
1185        output.push(alphabet[usize::from(first >> 2)]);
1186        output.push(alphabet[usize::from(((first & 0x03) << 4) | (second >> 4))]);
1187        if chunk.len() > 1 {
1188            output.push(alphabet[usize::from(((second & 0x0f) << 2) | (third >> 6))]);
1189        } else if padded {
1190            output.push(b'=');
1191        }
1192        if chunk.len() > 2 {
1193            output.push(alphabet[usize::from(third & 0x3f)]);
1194        } else if padded {
1195            output.push(b'=');
1196        }
1197    }
1198    output
1199}
1200
1201fn random_identity() -> Result<[u8; 16], ClientError> {
1202    let mut output = [0; 16];
1203    getrandom::fill(&mut output).map_err(|_| ClientError::RandomUnavailable)?;
1204    if output == [0; 16] {
1205        return Err(ClientError::RandomUnavailable);
1206    }
1207    Ok(output)
1208}
1209
1210fn normalized_timeout(timeout: Duration) -> Result<Duration, ClientError> {
1211    if timeout.is_zero() || timeout > MAX_CALL_TIMEOUT {
1212        return Err(ClientError::InvalidCallOptions);
1213    }
1214    let mut millis = timeout.as_millis();
1215    if !timeout.subsec_nanos().is_multiple_of(1_000_000) {
1216        millis = millis.checked_add(1).ok_or(ClientError::InvalidCallOptions)?;
1217    }
1218    let millis = u64::try_from(millis).map_err(|_| ClientError::InvalidCallOptions)?;
1219    let normalized = Duration::from_millis(millis);
1220    if normalized > MAX_CALL_TIMEOUT {
1221        return Err(ClientError::InvalidCallOptions);
1222    }
1223    Ok(normalized)
1224}
1225
1226fn duration_millis_ceil(duration: Duration) -> Result<u64, ClientError> {
1227    let mut millis = duration.as_millis();
1228    if !duration.subsec_nanos().is_multiple_of(1_000_000) {
1229        millis = millis.checked_add(1).ok_or(ClientError::ClockUnavailable)?;
1230    }
1231    u64::try_from(millis).map_err(|_| ClientError::ClockUnavailable)
1232}
1233
1234pub(crate) fn validate_index_name(value: &str) -> Result<(), ClientError> {
1235    if value.is_empty()
1236        || value.len() > 255
1237        || value
1238            .bytes()
1239            .any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace())
1240    {
1241        return Err(ClientError::InvalidCollectionIndexRef);
1242    }
1243    Ok(())
1244}
1245
1246fn lower_hex(bytes: &[u8]) -> String {
1247    const HEX: &[u8; 16] = b"0123456789abcdef";
1248    let mut output = String::with_capacity(bytes.len().saturating_mul(2));
1249    for byte in bytes {
1250        output.push(char::from(HEX[usize::from(byte >> 4)]));
1251        output.push(char::from(HEX[usize::from(byte & 0x0f)]));
1252    }
1253    output
1254}
1255
1256pub(crate) struct SensitiveBuffer(Vec<u8>);
1257
1258impl SensitiveBuffer {
1259    fn new() -> Self {
1260        Self(Vec::new())
1261    }
1262
1263    fn extend(&mut self, bytes: &[u8]) {
1264        self.0.extend_from_slice(bytes);
1265    }
1266
1267    fn extend_u32(&mut self, value: usize) -> Result<(), ClientError> {
1268        let value = u32::try_from(value).map_err(|_| ClientError::RequestTooLarge)?;
1269        self.extend(&value.to_be_bytes());
1270        Ok(())
1271    }
1272
1273    fn is_empty(&self) -> bool {
1274        self.0.is_empty()
1275    }
1276
1277    fn len(&self) -> usize {
1278        self.0.len()
1279    }
1280
1281    pub(crate) fn as_slice(&self) -> &[u8] {
1282        &self.0
1283    }
1284}
1285
1286impl Drop for SensitiveBuffer {
1287    fn drop(&mut self) {
1288        self.0.fill(0);
1289        std::hint::black_box(&mut self.0);
1290    }
1291}
1292
1293fn started_disposition(started: &AtomicBool) -> SubmissionDisposition {
1294    if started.load(Ordering::Acquire) {
1295        SubmissionDisposition::Indeterminate
1296    } else {
1297        SubmissionDisposition::NotSubmitted
1298    }
1299}
1300
1301#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1302pub enum SubmissionDisposition {
1303    NotSubmitted,
1304    Submitted,
1305    Resolved,
1306    KnownRetired,
1307    Indeterminate,
1308}
1309
1310#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1311pub enum TransportErrorKind {
1312    PoolClosed,
1313    Connect,
1314    Tls,
1315    Write,
1316    Read,
1317    Deadline,
1318    Cancelled,
1319}
1320
1321#[derive(Debug)]
1322pub enum ClientError {
1323    Config(ClientConfigError),
1324    Credential(CredentialProviderError),
1325    InvalidCallOptions,
1326    InvalidEndpoint,
1327    InvalidCollectionIndexRef,
1328    InvalidStateIndex,
1329    InvalidStateActionId,
1330    InvalidStateKey,
1331    InvalidStateBatch,
1332    UnexpectedStateResult,
1333    CollectionIndexRefRequired,
1334    CollectionIndexRefMismatch,
1335    ProfileNotVerified,
1336    ProfileIdentityMismatch,
1337    ProfileStreamMismatch,
1338    MissingCommandContract,
1339    RequestCodec,
1340    RequestTooLarge,
1341    InvalidRequestIdentity,
1342    ResponseCodec,
1343    ResponseTooLarge,
1344    NonCanonicalResponse,
1345    ResponseIdentityMismatch,
1346    ResponseCommandMismatch,
1347    InvalidErrorShape,
1348    RandomUnavailable,
1349    ClockUnavailable,
1350    InternalStateUnavailable,
1351    Transport {
1352        kind: TransportErrorKind,
1353        disposition: SubmissionDisposition,
1354    },
1355    Server(ErrorBody),
1356}
1357
1358impl ClientError {
1359    #[must_use]
1360    pub fn submission_disposition(&self) -> SubmissionDisposition {
1361        match self {
1362            Self::Transport { disposition, .. } => *disposition,
1363            Self::Server(error) => match server_error_disposition(error) {
1364                Some(disposition) => disposition,
1365                None => SubmissionDisposition::Indeterminate,
1366            },
1367            Self::UnexpectedStateResult
1368            | Self::ResponseCodec
1369            | Self::ResponseTooLarge
1370            | Self::NonCanonicalResponse
1371            | Self::ResponseIdentityMismatch
1372            | Self::ResponseCommandMismatch
1373            | Self::InvalidErrorShape => SubmissionDisposition::Indeterminate,
1374            _ => SubmissionDisposition::NotSubmitted,
1375        }
1376    }
1377
1378    pub(crate) fn is_deadline_exceeded(&self) -> bool {
1379        matches!(
1380            self,
1381            Self::Transport {
1382                kind: TransportErrorKind::Deadline,
1383                ..
1384            } | Self::Server(ErrorBody {
1385                code: lodestone_wire::ErrorCode::DeadlineExceeded,
1386                ..
1387            })
1388        )
1389    }
1390}
1391
1392fn server_error_disposition(error: &ErrorBody) -> Option<SubmissionDisposition> {
1393    let contract = LODESTONE_API_ERROR_OUTCOMES
1394        .iter()
1395        .find(|contract| contract.code == error.code)?;
1396    let value = if error.submitted {
1397        contract.when_submitted
1398    } else {
1399        contract.when_not_submitted
1400    }?;
1401    match value {
1402        "not_submitted" => Some(SubmissionDisposition::NotSubmitted),
1403        "submitted" => Some(SubmissionDisposition::Submitted),
1404        "resolved" => Some(SubmissionDisposition::Resolved),
1405        "known_retired" => Some(SubmissionDisposition::KnownRetired),
1406        "indeterminate" => Some(SubmissionDisposition::Indeterminate),
1407        _ => None,
1408    }
1409}
1410
1411impl From<ClientConfigError> for ClientError {
1412    fn from(error: ClientConfigError) -> Self {
1413        Self::Config(error)
1414    }
1415}
1416
1417impl Display for ClientError {
1418    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
1419        formatter.write_str(match self {
1420            Self::Config(_) => "Core client configuration is invalid",
1421            Self::Credential(_) => "Core credential material is unavailable or mismatched",
1422            Self::InvalidCallOptions => "Core call options are invalid",
1423            Self::InvalidEndpoint => "Core endpoint is invalid",
1424            Self::InvalidCollectionIndexRef => "Core request index scope is invalid",
1425            Self::InvalidStateIndex => "Lodestone state index identity is invalid",
1426            Self::InvalidStateActionId => "Lodestone state action identity is invalid",
1427            Self::InvalidStateKey => "Lodestone state key must contain 1 to 254 bytes",
1428            Self::InvalidStateBatch => "Lodestone state key batch is empty or exceeds its bound",
1429            Self::UnexpectedStateResult => "Lodestone state result differs from the requested operation",
1430            Self::CollectionIndexRefRequired => "Core request requires an exact index scope",
1431            Self::CollectionIndexRefMismatch => "Core request and admission index scopes differ",
1432            Self::ProfileNotVerified => "Core mutation is refused until discovery and binding are verified",
1433            Self::ProfileIdentityMismatch => "Core discovery or binding differs from the connection profile",
1434            Self::ProfileStreamMismatch => "Core selected stream differs from the connection profile",
1435            Self::MissingCommandContract => "generated Core command metadata is incomplete",
1436            Self::RequestCodec => "Core request is not a canonical current Lodestone API value",
1437            Self::RequestTooLarge => "Core request exceeds its generated byte bound",
1438            Self::InvalidRequestIdentity => "Core request identity is invalid",
1439            Self::ResponseCodec => "Core response is malformed or unsupported",
1440            Self::ResponseTooLarge => "Core response exceeds its generated byte bound",
1441            Self::NonCanonicalResponse => "Core response encoding is noncanonical",
1442            Self::ResponseIdentityMismatch => "Core response changed the request identity or version",
1443            Self::ResponseCommandMismatch => "Core response returned another command result",
1444            Self::InvalidErrorShape => "Core error detail does not match its generated code",
1445            Self::RandomUnavailable => "secure request identity generation is unavailable",
1446            Self::ClockUnavailable => "absolute Core request deadline cannot be calculated",
1447            Self::InternalStateUnavailable => "Core client verification state is unavailable",
1448            Self::Transport { .. } => "Core transport failed",
1449            Self::Server(_) => "Core returned a typed request error",
1450        })
1451    }
1452}
1453
1454impl Error for ClientError {}
1455
1456#[cfg(test)]
1457mod tests {
1458    use std::error::Error;
1459
1460    use lodestone_wire::{GetIndexRequest, Identity, OpenPitRequest, Resource, U64};
1461
1462    use super::*;
1463    use crate::SecretBytes;
1464
1465    #[test]
1466    fn response_failures_preserve_indeterminate_submission() {
1467        for error in [
1468            ClientError::UnexpectedStateResult,
1469            ClientError::ResponseCodec,
1470            ClientError::ResponseTooLarge,
1471            ClientError::NonCanonicalResponse,
1472            ClientError::ResponseIdentityMismatch,
1473            ClientError::ResponseCommandMismatch,
1474            ClientError::InvalidErrorShape,
1475        ] {
1476            assert_eq!(error.submission_disposition(), SubmissionDisposition::Indeterminate);
1477        }
1478        assert_eq!(
1479            ClientError::RequestCodec.submission_disposition(),
1480            SubmissionDisposition::NotSubmitted
1481        );
1482    }
1483
1484    fn cache_key(collection: u128, principal: u128, credential: u128) -> VerifiedSessionCacheKey {
1485        VerifiedSessionCacheKey {
1486            collection_id: format!("{collection:032x}"),
1487            principal_id: format!("{principal:032x}"),
1488            profile_generation: "1".to_owned(),
1489            credential_id: format!("{credential:032x}"),
1490            credential_generation: "1".to_owned(),
1491            secret_reference: format!("memory://session/{collection}/{principal}/{credential}"),
1492        }
1493    }
1494
1495    fn verification(collection: u128, principal: u128) -> ProfileVerification {
1496        ProfileVerification {
1497            artifact_version: "development".to_owned(),
1498            principal_id: Identity {
1499                value: format!("{principal:032x}"),
1500            },
1501            collection_id: Identity {
1502                value: format!("{collection:032x}"),
1503            },
1504            policy_revision: U64 { value: "1".to_owned() },
1505            cluster_id: Identity {
1506                value: "00000000000000000000000000000001".to_owned(),
1507            },
1508            configuration_generation: U64 { value: "1".to_owned() },
1509            binding_revision: U64 { value: "1".to_owned() },
1510            schema_id: LODESTONE_API_SCHEMA_ID.to_owned(),
1511            schema_digest: lodestone_wire::Digest {
1512                value: LODESTONE_API_SPEC_SHA256.to_owned(),
1513            },
1514            native_endpoints: vec!["lodestone+tls://core.example:8443".to_owned()],
1515            selected_stream_endpoint: "https://core.example:9443".to_owned(),
1516        }
1517    }
1518
1519    #[test]
1520    fn mutation_retry_stops_at_the_submission_boundary() {
1521        assert!(retry_admitted(true, true, SubmissionDisposition::NotSubmitted));
1522        assert!(!retry_admitted(true, true, SubmissionDisposition::Submitted));
1523        assert!(!retry_admitted(true, true, SubmissionDisposition::Indeterminate));
1524        assert!(!retry_admitted(true, false, SubmissionDisposition::NotSubmitted));
1525        assert!(retry_admitted(false, true, SubmissionDisposition::Indeterminate));
1526    }
1527
1528    #[test]
1529    fn one_deadline_survives_connect_state_and_resolution_clones() -> Result<(), Box<dyn Error>> {
1530        let options = CallOptions::new(Duration::from_secs(5))?;
1531        let absolute = options.absolute_deadline_unix_millis()?;
1532        let local = options.local_deadline()?;
1533
1534        // These clones stand for discovery, binding, prepare, submit, and
1535        // resolution. None may derive a fresh duration from the current time.
1536        for stage in [
1537            options.clone(),
1538            options.clone(),
1539            options.clone(),
1540            options.clone(),
1541            options.for_resolution(),
1542        ] {
1543            assert_eq!(stage.absolute_deadline_unix_millis()?, absolute);
1544            assert_eq!(stage.local_deadline()?, local);
1545        }
1546
1547        let resolution = options.for_resolution();
1548        options.cancellation().cancel();
1549        assert!(options.cancellation().is_cancelled());
1550        assert!(!resolution.cancellation().is_cancelled());
1551        assert_eq!(resolution.absolute_deadline_unix_millis()?, absolute);
1552        Ok(())
1553    }
1554
1555    #[test]
1556    fn caller_supplied_absolute_deadline_is_preserved_exactly() -> Result<(), Box<dyn Error>> {
1557        let now = SystemTime::now().duration_since(UNIX_EPOCH)?;
1558        let now_millis = u64::try_from(now.as_millis())?;
1559        let absolute = now_millis.checked_add(5_000).ok_or("test deadline overflow")?;
1560        let options = CallOptions::from_absolute_deadline_unix_millis(absolute)?;
1561        assert_eq!(options.absolute_deadline_unix_millis()?, absolute);
1562
1563        let too_far = now_millis
1564            .checked_add(u64::try_from(MAX_CALL_TIMEOUT.as_millis())?)
1565            .and_then(|value| value.checked_add(1_000))
1566            .ok_or("test deadline overflow")?;
1567        assert!(matches!(
1568            CallOptions::from_absolute_deadline_unix_millis(too_far),
1569            Err(ClientError::InvalidCallOptions)
1570        ));
1571        assert_eq!(normalized_timeout(Duration::from_nanos(1))?, Duration::from_millis(1));
1572        Ok(())
1573    }
1574
1575    #[test]
1576    fn private_api_key_encodings_match_the_core_admission_contract() -> Result<(), Box<dyn Error>> {
1577        let material = CredentialMaterial::new(
1578            Identity {
1579                value: "00000000000000000000000000000001".to_owned(),
1580            },
1581            U64 { value: "1".to_owned() },
1582            SecretBytes::from_array([0; 32]),
1583        )?;
1584        assert_eq!(
1585            native_credential(&material)?.as_slice(),
1586            b"1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
1587        );
1588        assert_eq!(
1589            authorization_value(&material)?.as_slice(),
1590            b"ApiKey MTpBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB"
1591        );
1592        Ok(())
1593    }
1594
1595    #[test]
1596    fn collection_local_refusal_keeps_the_shared_connection_reusable() {
1597        let refusal = Err(ClientError::Server(ErrorBody {
1598            code: lodestone_wire::ErrorCode::PermissionDenied,
1599            message: "denied".to_owned(),
1600            retry_safe: false,
1601            submitted: false,
1602            details: None,
1603        }));
1604        assert!(response_keeps_connection(&refusal));
1605        let failure = Err(ClientError::Transport {
1606            kind: TransportErrorKind::Read,
1607            disposition: SubmissionDisposition::Indeterminate,
1608        });
1609        assert!(!response_keeps_connection(&failure));
1610    }
1611
1612    #[test]
1613    fn verified_session_cache_is_bounded_and_never_crosses_principals() {
1614        let metrics = CellTransportMetrics::default();
1615        let mut cache = VerifiedSessionCache::default();
1616        let first = cache_key(1, 10, 100);
1617        let other_principal = cache_key(1, 11, 100);
1618        assert_ne!(first, other_principal);
1619        cache.insert(first.clone(), verification(1, 10), &metrics);
1620        assert!(cache.get(&other_principal, &metrics).is_none());
1621        assert_eq!(cache.get(&first, &metrics), Some(verification(1, 10)));
1622
1623        for offset in 0..=MAX_VERIFIED_SESSION_METADATA {
1624            let identity = u128::try_from(offset).unwrap_or_default().saturating_add(1_000);
1625            cache.insert(
1626                cache_key(identity, identity, identity),
1627                verification(identity, identity),
1628                &metrics,
1629            );
1630        }
1631        assert_eq!(cache.entries.len(), MAX_VERIFIED_SESSION_METADATA);
1632        assert!(metrics.session_cache_evictions.load(Ordering::Relaxed) > 0);
1633        assert_eq!(
1634            metrics.session_cache_entries.load(Ordering::Relaxed),
1635            MAX_VERIFIED_SESSION_METADATA
1636        );
1637        assert_eq!(metrics.session_cache_hits.load(Ordering::Relaxed), 1);
1638        assert_eq!(metrics.session_cache_misses.load(Ordering::Relaxed), 1);
1639    }
1640
1641    #[test]
1642    fn generated_multi_index_scope_requires_a_nonempty_distinct_set() {
1643        fn index(name: &str, incarnation: &str) -> Resource {
1644            Resource {
1645                kind: "index".to_owned(),
1646                name: name.to_owned(),
1647                incarnation: Identity {
1648                    value: incarnation.to_owned(),
1649                },
1650            }
1651        }
1652
1653        fn request(indexes: Vec<Resource>) -> RequestBody {
1654            RequestBody::OpenPit(OpenPitRequest {
1655                indexes,
1656                keep_alive_millis: U64 {
1657                    value: "1000".to_owned(),
1658                },
1659            })
1660        }
1661
1662        let first = index("orders", "00000000000000000000000000000001");
1663        let second = index("events", "00000000000000000000000000000002");
1664        assert!(matches!(
1665            resolve_index_name(&request(Vec::new()), None),
1666            Err(ClientError::CollectionIndexRefRequired)
1667        ));
1668        assert!(matches!(
1669            resolve_index_name(&request(vec![first.clone(), second.clone()]), None),
1670            Ok(None)
1671        ));
1672        assert!(matches!(
1673            resolve_index_name(&request(vec![first.clone(), first.clone()]), None),
1674            Err(ClientError::CollectionIndexRefMismatch)
1675        ));
1676        assert!(matches!(
1677            resolve_index_name(&request(vec![second.clone(), first.clone(), first]), None),
1678            Err(ClientError::CollectionIndexRefMismatch)
1679        ));
1680    }
1681
1682    #[test]
1683    fn generated_get_index_is_collection_scoped() {
1684        let request = RequestBody::GetIndex(GetIndexRequest {
1685            name: "events".to_owned(),
1686            expected_incarnation: None,
1687        });
1688
1689        assert!(matches!(resolve_index_name(&request, None), Ok(None)));
1690        assert!(matches!(
1691            resolve_index_name(&request, Some("events")),
1692            Err(ClientError::CollectionIndexRefMismatch)
1693        ));
1694    }
1695}