Skip to main content

lodestone_client/
transport.rs

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