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 pool_config(&self) -> PoolConfig {
936        self.inner.transport.pool_config()
937    }
938}
939
940fn response_keeps_connection(result: &Result<ResultBody, ClientError>) -> bool {
941    result.is_ok()
942}
943
944const MAX_REUSABLE_CONNECTION_IDLE: Duration = Duration::from_secs(1);
945
946fn connection_idle_is_reusable(idle_since: tokio::time::Instant, now: tokio::time::Instant) -> bool {
947    now.checked_duration_since(idle_since)
948        .is_some_and(|idle| idle <= MAX_REUSABLE_CONNECTION_IDLE)
949}
950
951const fn default_workload_class(action: Action) -> WorkloadClass {
952    match action {
953        Action::DataRead | Action::SubscriptionRead => WorkloadClass::InteractiveSearch,
954        Action::DataWrite | Action::Append | Action::ResolveAppend => WorkloadClass::StreamingIngest,
955        Action::IdentityDiscovery
956        | Action::IndexAdmin
957        | Action::CatalogRead
958        | Action::TaskRead
959        | Action::DiagnosticsRead
960        | Action::DiagnosticsCreate
961        | Action::SecurityRead
962        | Action::SecurityAdmin
963        | Action::AuditRead
964        | Action::QuotaRead
965        | Action::QuotaAdmin
966        | Action::BindingRead
967        | Action::LifecycleAdmin
968        | Action::AdmissionRead
969        | Action::AdmissionAdmin
970        | Action::UsageRead
971        | Action::OperationRead
972        | Action::OperationCancel
973        | Action::CollectionExport
974        | Action::CollectionRestore
975        | Action::CollectionTransferExport
976        | Action::CollectionTransferRestore
977        | Action::IndexExport
978        | Action::IndexImport => WorkloadClass::BatchAnalytics,
979    }
980}
981
982#[derive(serde::Serialize)]
983struct PublicSession<'a> {
984    format_version: u16,
985    api_key_credential: &'a [u8],
986    collection_id: &'a str,
987    request_id: &'a str,
988    action: &'static str,
989    workload_class: WorkloadClass,
990    #[serde(skip_serializing_if = "Option::is_none")]
991    index_name: Option<&'a str>,
992    request_correlation_id: [u8; 16],
993}
994
995fn canonical_request(envelope: &RequestEnvelope) -> Result<Vec<u8>, ClientError> {
996    if envelope.meta.request_id.is_empty()
997        || envelope.meta.request_id.len() > MAX_REQUEST_ID_BYTES
998        || envelope.meta.request_id.bytes().any(|byte| byte.is_ascii_control())
999    {
1000        return Err(ClientError::InvalidRequestIdentity);
1001    }
1002    let payload = serde_json::to_vec(envelope).map_err(|_| ClientError::RequestCodec)?;
1003    if payload.is_empty() || payload.len() > MAX_NATIVE_REQUEST_BYTES {
1004        return Err(ClientError::RequestTooLarge);
1005    }
1006    let decoded: RequestEnvelope = serde_json::from_slice(&payload).map_err(|_| ClientError::RequestCodec)?;
1007    if decoded != *envelope {
1008        return Err(ClientError::RequestCodec);
1009    }
1010    Ok(payload)
1011}
1012
1013fn build_tls_config(config: &CellTransportConfig) -> Result<rustls::ClientConfig, ClientError> {
1014    let mut roots = rustls::RootCertStore::empty();
1015    for certificate in config.trust.certificates_der() {
1016        roots
1017            .add(CertificateDer::from(certificate.clone()))
1018            .map_err(|_| ClientError::Config(ClientConfigError::InvalidTrustMaterial))?;
1019    }
1020    let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
1021    let mut tls = rustls::ClientConfig::builder_with_provider(provider)
1022        .with_protocol_versions(&[&rustls::version::TLS13])
1023        .map_err(|_| ClientError::Config(ClientConfigError::InvalidTrustMaterial))?
1024        .with_root_certificates(roots)
1025        .with_no_client_auth();
1026    tls.alpn_protocols = vec![LODESTONE_API_ALPN.as_bytes().to_vec()];
1027    Ok(tls)
1028}
1029
1030fn validate_discovery(config: &CollectionSessionConfig, discovered: &DiscoverResult) -> Result<(), ClientError> {
1031    let profile = config.profile.as_wire();
1032    if discovered.collection_id != profile.expected_collection_id
1033        || discovered.principal_id != profile.expected_principal_id
1034        || discovered.schema_id != LODESTONE_API_SCHEMA_ID
1035        || discovered.schema_digest.value != LODESTONE_API_SPEC_SHA256
1036    {
1037        return Err(ClientError::ProfileIdentityMismatch);
1038    }
1039    let expected = LODESTONE_API_STREAM_CONTRACTS
1040        .first()
1041        .ok_or(ClientError::MissingCommandContract)?;
1042    if discovered.streams.len() != 1
1043        || discovered.streams.first().is_none_or(|stream| {
1044            stream.name != expected.name
1045                || stream.transport != expected.transport
1046                || stream.service != expected.service
1047                || stream.method != expected.method
1048                || stream.endpoint != profile.selected_stream_endpoint
1049                || stream.auth_metadata != expected.auth_metadata
1050                || stream.auth_scheme != expected.auth_scheme
1051                || stream.semantic_format_version != u32::from(expected.semantic_format_version)
1052                || stream.schema_digest.value != LODESTONE_API_SPEC_SHA256
1053        })
1054    {
1055        return Err(ClientError::ProfileStreamMismatch);
1056    }
1057    Ok(())
1058}
1059
1060fn validate_binding(
1061    config: &CollectionSessionConfig,
1062    binding: &CoreBinding,
1063    derived: bool,
1064    revision: &U64,
1065) -> Result<(), ClientError> {
1066    let profile = config.profile.as_wire();
1067    if !derived
1068        || binding.cluster_id != profile.expected_cluster_id
1069        || binding.collection_id != profile.expected_collection_id
1070        || binding.native_endpoints != profile.native_endpoints
1071        || binding.selected_stream_endpoint != profile.selected_stream_endpoint
1072        || binding.schema_id != profile.schema_id
1073        || binding.schema_digest != profile.schema_digest
1074        || parse_u64(&binding.configuration_generation.value).is_none_or(|generation| generation == 0)
1075        || parse_u64(&revision.value).is_none_or(|revision| revision == 0)
1076    {
1077        return Err(ClientError::ProfileIdentityMismatch);
1078    }
1079    validate_storage_profile(profile, binding, revision)?;
1080    Ok(())
1081}
1082
1083fn validate_storage_profile(
1084    profile: &lodestone_wire::ConnectionProfile,
1085    binding: &CoreBinding,
1086    binding_revision: &U64,
1087) -> Result<(), ClientError> {
1088    let attestation = &binding.storage_profile;
1089    let expected_roles = [
1090        StorageProfileRole::Control,
1091        StorageProfileRole::DefaultWal,
1092        StorageProfileRole::DefaultData,
1093    ];
1094    let roles_are_canonical = attestation.roles.len() == expected_roles.len()
1095        && attestation
1096            .roles
1097            .iter()
1098            .zip(expected_roles)
1099            .all(|(binding, expected)| binding.role == expected)
1100        && canonical_authority_groups(&attestation.roles);
1101    if attestation.format_version != u32::from(LODESTONE_API_STORAGE_PROFILE_ATTESTATION_FORMAT_VERSION)
1102        || parse_u64(&attestation.configuration_generation.value) != parse_u64(&binding.configuration_generation.value)
1103        || parse_u64(&binding_revision.value).is_none_or(|revision| revision == 0)
1104        || !roles_are_canonical
1105        || attestation.default_policy_digest.validate().is_err()
1106        || attestation.storage_profile_digest.validate().is_err()
1107        || attestation.attestation_digest.validate().is_err()
1108    {
1109        return Err(ClientError::ProfileIdentityMismatch);
1110    }
1111    if profile.storage_profile_expectation.as_ref().is_some_and(|expectation| {
1112        expectation
1113            .required_posture
1114            .is_some_and(|required| required != attestation.overall_posture)
1115            || expectation
1116                .expected_storage_profile_digest
1117                .as_ref()
1118                .is_some_and(|expected| expected != &attestation.storage_profile_digest)
1119    }) {
1120        return Err(ClientError::StorageProfileMismatch);
1121    }
1122    Ok(())
1123}
1124
1125fn canonical_authority_groups(roles: &[lodestone_wire::StorageProfileRoleBinding]) -> bool {
1126    let Some(first) = roles.first() else {
1127        return false;
1128    };
1129    if first.authority_group != 0 {
1130        return false;
1131    }
1132    let mut group_count = 1_usize;
1133    for role in roles.iter().skip(1) {
1134        let Ok(group) = usize::try_from(role.authority_group) else {
1135            return false;
1136        };
1137        if group < group_count {
1138            continue;
1139        }
1140        if group != group_count {
1141            return false;
1142        }
1143        group_count += 1;
1144    }
1145    true
1146}
1147
1148fn validate_cached_verification(
1149    config: &CollectionSessionConfig,
1150    verification: &ProfileVerification,
1151) -> Result<(), ClientError> {
1152    let profile = config.profile.as_wire();
1153    if verification.collection_id != profile.expected_collection_id
1154        || verification.principal_id != profile.expected_principal_id
1155        || verification.cluster_id != profile.expected_cluster_id
1156        || verification.schema_id != profile.schema_id
1157        || verification.schema_digest != profile.schema_digest
1158        || verification.native_endpoints != profile.native_endpoints
1159        || verification.selected_stream_endpoint != profile.selected_stream_endpoint
1160        || verification
1161            .configuration_generation
1162            .value
1163            .parse::<u64>()
1164            .ok()
1165            .is_none_or(|generation| generation == 0)
1166        || verification
1167            .binding_revision
1168            .value
1169            .parse::<u64>()
1170            .ok()
1171            .is_none_or(|revision| revision == 0)
1172    {
1173        return Err(ClientError::ProfileIdentityMismatch);
1174    }
1175    let cached_binding = CoreBinding {
1176        cluster_id: verification.cluster_id.clone(),
1177        collection_id: verification.collection_id.clone(),
1178        configuration_generation: verification.configuration_generation.clone(),
1179        native_endpoints: verification.native_endpoints.clone(),
1180        selected_stream_endpoint: verification.selected_stream_endpoint.clone(),
1181        schema_id: verification.schema_id.clone(),
1182        schema_digest: verification.schema_digest.clone(),
1183        storage_profile: verification.storage_profile.clone(),
1184    };
1185    validate_storage_profile(profile, &cached_binding, &verification.binding_revision)?;
1186    Ok(())
1187}
1188
1189fn resolve_index_name(request: &RequestBody, supplied: Option<&str>) -> Result<Option<String>, ClientError> {
1190    let scope = request_collection_index_ref(request)?;
1191    let inferred = match &scope {
1192        GeneratedCollectionIndexRef::Exact(name) => Some(name.clone()),
1193        GeneratedCollectionIndexRef::None | GeneratedCollectionIndexRef::Caller => None,
1194    };
1195    if let Some(supplied) = supplied {
1196        validate_index_name(supplied)?;
1197        if inferred.as_deref().is_some_and(|inferred| inferred != supplied) {
1198            return Err(ClientError::CollectionIndexRefMismatch);
1199        }
1200    }
1201    let resolved = supplied.map(str::to_owned).or(inferred);
1202    if let Some(name) = &resolved {
1203        validate_index_name(name)?;
1204    }
1205    match scope {
1206        GeneratedCollectionIndexRef::Caller | GeneratedCollectionIndexRef::Exact(_) if resolved.is_none() => {
1207            return Err(ClientError::CollectionIndexRefRequired);
1208        }
1209        GeneratedCollectionIndexRef::None if resolved.is_some() => return Err(ClientError::CollectionIndexRefMismatch),
1210        GeneratedCollectionIndexRef::None
1211        | GeneratedCollectionIndexRef::Caller
1212        | GeneratedCollectionIndexRef::Exact(_) => {}
1213    }
1214    Ok(resolved)
1215}
1216
1217const fn action_name(action: Action) -> &'static str {
1218    match action {
1219        Action::IdentityDiscovery => "identity_discovery",
1220        Action::DataRead => "data_read",
1221        Action::DataWrite => "data_write",
1222        Action::Append => "append",
1223        Action::ResolveAppend => "resolve_append",
1224        Action::IndexAdmin => "index_admin",
1225        Action::CatalogRead => "catalog_read",
1226        Action::TaskRead => "task_read",
1227        Action::SubscriptionRead => "subscription_read",
1228        Action::DiagnosticsRead => "diagnostics_read",
1229        Action::DiagnosticsCreate => "diagnostics_create",
1230        Action::SecurityRead => "security_read",
1231        Action::SecurityAdmin => "security_admin",
1232        Action::AuditRead => "audit_read",
1233        Action::QuotaRead => "quota_read",
1234        Action::QuotaAdmin => "quota_admin",
1235        Action::BindingRead => "binding_read",
1236        Action::LifecycleAdmin => "lifecycle_admin",
1237        Action::AdmissionRead => "admission_read",
1238        Action::AdmissionAdmin => "admission_admin",
1239        Action::UsageRead => "usage_read",
1240        Action::OperationRead => "operation_read",
1241        Action::OperationCancel => "operation_cancel",
1242        Action::CollectionExport => "collection_export",
1243        Action::CollectionRestore => "collection_restore",
1244        Action::CollectionTransferExport => "collection_transfer_export",
1245        Action::CollectionTransferRestore => "collection_transfer_restore",
1246        Action::IndexExport => "index_export",
1247        Action::IndexImport => "index_import",
1248    }
1249}
1250
1251fn native_credential(material: &CredentialMaterial) -> Result<SensitiveBuffer, ClientError> {
1252    let identifier = parse_identity(&material.api_key_id().value)
1253        .ok_or(ClientError::Credential(CredentialProviderError::InvalidMaterial))?;
1254    let mut output = SensitiveBuffer::new();
1255    output.extend(identifier.to_string().as_bytes());
1256    output.extend(b":");
1257    output.extend(&base64_url_no_pad(material.secret().expose()));
1258    Ok(output)
1259}
1260
1261pub(crate) fn authorization_value(material: &CredentialMaterial) -> Result<SensitiveBuffer, ClientError> {
1262    let native = native_credential(material)?;
1263    let mut output = SensitiveBuffer::new();
1264    output.extend(b"ApiKey ");
1265    output.extend(&base64_standard(native.as_slice()));
1266    Ok(output)
1267}
1268
1269fn base64_url_no_pad(bytes: &[u8]) -> Vec<u8> {
1270    base64_encode(
1271        bytes,
1272        b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_",
1273        false,
1274    )
1275}
1276
1277fn base64_standard(bytes: &[u8]) -> Vec<u8> {
1278    base64_encode(
1279        bytes,
1280        b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",
1281        true,
1282    )
1283}
1284
1285/// Converts exact UTF-8 text into the authoritative Lodestone API raw JSON wire
1286/// leaf without parsing or normalizing the text.
1287pub fn raw_json_text(text: &str) -> Result<RawJson, ClientError> {
1288    let bytes_base64 = String::from_utf8(base64_standard(text.as_bytes())).map_err(|_| ClientError::RequestCodec)?;
1289    Ok(RawJson { bytes_base64 })
1290}
1291
1292fn base64_encode(bytes: &[u8], alphabet: &[u8; 64], padded: bool) -> Vec<u8> {
1293    let mut output = Vec::with_capacity(bytes.len().div_ceil(3).saturating_mul(4));
1294    for chunk in bytes.chunks(3) {
1295        let first = chunk[0];
1296        let second = chunk.get(1).copied().unwrap_or(0);
1297        let third = chunk.get(2).copied().unwrap_or(0);
1298        output.push(alphabet[usize::from(first >> 2)]);
1299        output.push(alphabet[usize::from(((first & 0x03) << 4) | (second >> 4))]);
1300        if chunk.len() > 1 {
1301            output.push(alphabet[usize::from(((second & 0x0f) << 2) | (third >> 6))]);
1302        } else if padded {
1303            output.push(b'=');
1304        }
1305        if chunk.len() > 2 {
1306            output.push(alphabet[usize::from(third & 0x3f)]);
1307        } else if padded {
1308            output.push(b'=');
1309        }
1310    }
1311    output
1312}
1313
1314fn random_identity() -> Result<[u8; 16], ClientError> {
1315    let mut output = [0; 16];
1316    getrandom::fill(&mut output).map_err(|_| ClientError::RandomUnavailable)?;
1317    if output == [0; 16] {
1318        return Err(ClientError::RandomUnavailable);
1319    }
1320    Ok(output)
1321}
1322
1323fn normalized_timeout(timeout: Duration) -> Result<Duration, ClientError> {
1324    if timeout.is_zero() || timeout > MAX_CALL_TIMEOUT {
1325        return Err(ClientError::InvalidCallOptions);
1326    }
1327    let mut millis = timeout.as_millis();
1328    if !timeout.subsec_nanos().is_multiple_of(1_000_000) {
1329        millis = millis.checked_add(1).ok_or(ClientError::InvalidCallOptions)?;
1330    }
1331    let millis = u64::try_from(millis).map_err(|_| ClientError::InvalidCallOptions)?;
1332    let normalized = Duration::from_millis(millis);
1333    if normalized > MAX_CALL_TIMEOUT {
1334        return Err(ClientError::InvalidCallOptions);
1335    }
1336    Ok(normalized)
1337}
1338
1339fn duration_millis_ceil(duration: Duration) -> Result<u64, ClientError> {
1340    let mut millis = duration.as_millis();
1341    if !duration.subsec_nanos().is_multiple_of(1_000_000) {
1342        millis = millis.checked_add(1).ok_or(ClientError::ClockUnavailable)?;
1343    }
1344    u64::try_from(millis).map_err(|_| ClientError::ClockUnavailable)
1345}
1346
1347pub(crate) fn validate_index_name(value: &str) -> Result<(), ClientError> {
1348    if value.is_empty()
1349        || value.len() > 255
1350        || value
1351            .bytes()
1352            .any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace())
1353    {
1354        return Err(ClientError::InvalidCollectionIndexRef);
1355    }
1356    Ok(())
1357}
1358
1359fn lower_hex(bytes: &[u8]) -> String {
1360    const HEX: &[u8; 16] = b"0123456789abcdef";
1361    let mut output = String::with_capacity(bytes.len().saturating_mul(2));
1362    for byte in bytes {
1363        output.push(char::from(HEX[usize::from(byte >> 4)]));
1364        output.push(char::from(HEX[usize::from(byte & 0x0f)]));
1365    }
1366    output
1367}
1368
1369pub(crate) struct SensitiveBuffer(Vec<u8>);
1370
1371impl SensitiveBuffer {
1372    fn new() -> Self {
1373        Self(Vec::new())
1374    }
1375
1376    fn extend(&mut self, bytes: &[u8]) {
1377        self.0.extend_from_slice(bytes);
1378    }
1379
1380    fn extend_u32(&mut self, value: usize) -> Result<(), ClientError> {
1381        let value = u32::try_from(value).map_err(|_| ClientError::RequestTooLarge)?;
1382        self.extend(&value.to_be_bytes());
1383        Ok(())
1384    }
1385
1386    fn is_empty(&self) -> bool {
1387        self.0.is_empty()
1388    }
1389
1390    fn len(&self) -> usize {
1391        self.0.len()
1392    }
1393
1394    pub(crate) fn as_slice(&self) -> &[u8] {
1395        &self.0
1396    }
1397}
1398
1399impl Drop for SensitiveBuffer {
1400    fn drop(&mut self) {
1401        self.0.fill(0);
1402        std::hint::black_box(&mut self.0);
1403    }
1404}
1405
1406fn started_disposition(started: &AtomicBool) -> SubmissionDisposition {
1407    if started.load(Ordering::Acquire) {
1408        SubmissionDisposition::Indeterminate
1409    } else {
1410        SubmissionDisposition::NotSubmitted
1411    }
1412}
1413
1414#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1415pub enum SubmissionDisposition {
1416    NotSubmitted,
1417    Submitted,
1418    Resolved,
1419    KnownRetired,
1420    Indeterminate,
1421}
1422
1423#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1424pub enum TransportErrorKind {
1425    PoolClosed,
1426    Connect,
1427    Tls,
1428    Write,
1429    Read,
1430    Deadline,
1431    Cancelled,
1432}
1433
1434#[derive(Debug)]
1435pub enum ClientError {
1436    Config(ClientConfigError),
1437    Credential(CredentialProviderError),
1438    InvalidCallOptions,
1439    InvalidEndpoint,
1440    InvalidCollectionIndexRef,
1441    InvalidStateIndex,
1442    InvalidStateActionId,
1443    InvalidStateKey,
1444    InvalidStateBatch,
1445    UnexpectedStateResult,
1446    CollectionIndexRefRequired,
1447    CollectionIndexRefMismatch,
1448    ProfileNotVerified,
1449    ProfileIdentityMismatch,
1450    ProfileStreamMismatch,
1451    StorageProfileMismatch,
1452    MissingCommandContract,
1453    RequestCodec,
1454    RequestTooLarge,
1455    InvalidRequestIdentity,
1456    ResponseCodec,
1457    ResponseTooLarge,
1458    NonCanonicalResponse,
1459    ResponseIdentityMismatch,
1460    ResponseCommandMismatch,
1461    InvalidErrorShape,
1462    RandomUnavailable,
1463    ClockUnavailable,
1464    InternalStateUnavailable,
1465    Transport {
1466        kind: TransportErrorKind,
1467        disposition: SubmissionDisposition,
1468    },
1469    Server(ErrorBody),
1470}
1471
1472impl ClientError {
1473    #[must_use]
1474    pub fn submission_disposition(&self) -> SubmissionDisposition {
1475        match self {
1476            Self::Transport { disposition, .. } => *disposition,
1477            Self::Server(error) => server_error_disposition(error),
1478            Self::UnexpectedStateResult
1479            | Self::ResponseCodec
1480            | Self::ResponseTooLarge
1481            | Self::NonCanonicalResponse
1482            | Self::ResponseIdentityMismatch
1483            | Self::ResponseCommandMismatch
1484            | Self::InvalidErrorShape => SubmissionDisposition::Indeterminate,
1485            _ => SubmissionDisposition::NotSubmitted,
1486        }
1487    }
1488
1489    /// Returns whether the current typed outcome permits an automatic replay.
1490    /// Indeterminate transport outcomes are always resolution-required; no
1491    /// command-level boolean may widen this decision.
1492    #[must_use]
1493    pub fn automatic_retry_admitted(&self) -> bool {
1494        matches!(
1495            self.retry_directive(),
1496            Some((
1497                ErrorDisposition::NotSubmitted,
1498                RecoveryAction::Retry | RecoveryAction::RetryAfter
1499            ))
1500        )
1501    }
1502
1503    fn retry_directive(&self) -> Option<(ErrorDisposition, RecoveryAction)> {
1504        match self {
1505            Self::Server(error) => Some((error.disposition, error.recovery)),
1506            Self::Transport {
1507                kind: TransportErrorKind::Deadline | TransportErrorKind::Cancelled,
1508                ..
1509            } => Some((ErrorDisposition::NotSubmitted, RecoveryAction::Stop)),
1510            Self::Transport {
1511                disposition: SubmissionDisposition::NotSubmitted,
1512                ..
1513            } => Some((ErrorDisposition::NotSubmitted, RecoveryAction::Retry)),
1514            Self::Transport { .. } => Some((ErrorDisposition::Indeterminate, RecoveryAction::Resolve)),
1515            _ => None,
1516        }
1517    }
1518
1519    pub(crate) fn is_deadline_exceeded(&self) -> bool {
1520        matches!(
1521            self,
1522            Self::Transport {
1523                kind: TransportErrorKind::Deadline,
1524                ..
1525            } | Self::Server(ErrorBody {
1526                code: lodestone_wire::ErrorCode::DeadlineExceeded,
1527                ..
1528            })
1529        )
1530    }
1531}
1532
1533fn server_error_disposition(error: &ErrorBody) -> SubmissionDisposition {
1534    match error.disposition {
1535        lodestone_wire::ErrorDisposition::NotSubmitted => SubmissionDisposition::NotSubmitted,
1536        lodestone_wire::ErrorDisposition::Submitted => SubmissionDisposition::Submitted,
1537        lodestone_wire::ErrorDisposition::Resolved => SubmissionDisposition::Resolved,
1538        lodestone_wire::ErrorDisposition::KnownRetired => SubmissionDisposition::KnownRetired,
1539        lodestone_wire::ErrorDisposition::Indeterminate => SubmissionDisposition::Indeterminate,
1540    }
1541}
1542
1543fn response_meta_matches(meta: &lodestone_wire::ResponseMeta, request_id: &str, correlation: &[u8; 16]) -> bool {
1544    meta.format_version == u32::from(LODESTONE_API_RESPONSE_META_FORMAT_VERSION)
1545        && meta.request_id == request_id
1546        && meta.correlation_id.value == lower_hex(correlation)
1547        && canonical_identity(&meta.diagnostic_id.value)
1548}
1549
1550fn canonical_identity(value: &str) -> bool {
1551    value.len() == 32
1552        && value
1553            .bytes()
1554            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1555        && value.bytes().any(|byte| byte != b'0')
1556}
1557
1558impl From<ClientConfigError> for ClientError {
1559    fn from(error: ClientConfigError) -> Self {
1560        Self::Config(error)
1561    }
1562}
1563
1564impl Display for ClientError {
1565    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
1566        formatter.write_str(match self {
1567            Self::Config(_) => "Core client configuration is invalid",
1568            Self::Credential(_) => "Core credential material is unavailable or mismatched",
1569            Self::InvalidCallOptions => "Core call options are invalid",
1570            Self::InvalidEndpoint => "Core endpoint is invalid",
1571            Self::InvalidCollectionIndexRef => "Core request index scope is invalid",
1572            Self::InvalidStateIndex => "Lodestone state index identity is invalid",
1573            Self::InvalidStateActionId => "Lodestone state action identity is invalid",
1574            Self::InvalidStateKey => "Lodestone state key must contain 1 to 254 bytes",
1575            Self::InvalidStateBatch => "Lodestone state key batch is empty or exceeds its bound",
1576            Self::UnexpectedStateResult => "Lodestone state result differs from the requested operation",
1577            Self::CollectionIndexRefRequired => "Core request requires an exact index scope",
1578            Self::CollectionIndexRefMismatch => "Core request and admission index scopes differ",
1579            Self::ProfileNotVerified => "Core mutation is refused until discovery and binding are verified",
1580            Self::ProfileIdentityMismatch => "Core discovery or binding differs from the connection profile",
1581            Self::ProfileStreamMismatch => "Core selected stream differs from the connection profile",
1582            Self::StorageProfileMismatch => "Core storage profile differs from the connection profile expectation",
1583            Self::MissingCommandContract => "generated Core command metadata is incomplete",
1584            Self::RequestCodec => "Core request is not a canonical current Lodestone API value",
1585            Self::RequestTooLarge => "Core request exceeds its generated byte bound",
1586            Self::InvalidRequestIdentity => "Core request identity is invalid",
1587            Self::ResponseCodec => "Core response is malformed or unsupported",
1588            Self::ResponseTooLarge => "Core response exceeds its generated byte bound",
1589            Self::NonCanonicalResponse => "Core response encoding is noncanonical",
1590            Self::ResponseIdentityMismatch => "Core response changed the request identity or version",
1591            Self::ResponseCommandMismatch => "Core response returned another command result",
1592            Self::InvalidErrorShape => "Core error detail does not match its generated code",
1593            Self::RandomUnavailable => "secure request identity generation is unavailable",
1594            Self::ClockUnavailable => "absolute Core request deadline cannot be calculated",
1595            Self::InternalStateUnavailable => "Core client verification state is unavailable",
1596            Self::Transport { .. } => "Core transport failed",
1597            Self::Server(_) => "Core returned a typed request error",
1598        })
1599    }
1600}
1601
1602impl Error for ClientError {}
1603
1604#[cfg(test)]
1605mod tests {
1606    use std::error::Error;
1607
1608    use lodestone_wire::{GetIndexRequest, Identity, OpenPitRequest, Resource, U64};
1609
1610    use super::*;
1611    use crate::SecretBytes;
1612
1613    #[test]
1614    fn response_failures_preserve_indeterminate_submission() {
1615        for error in [
1616            ClientError::UnexpectedStateResult,
1617            ClientError::ResponseCodec,
1618            ClientError::ResponseTooLarge,
1619            ClientError::NonCanonicalResponse,
1620            ClientError::ResponseIdentityMismatch,
1621            ClientError::ResponseCommandMismatch,
1622            ClientError::InvalidErrorShape,
1623        ] {
1624            assert_eq!(error.submission_disposition(), SubmissionDisposition::Indeterminate);
1625        }
1626        assert_eq!(
1627            ClientError::RequestCodec.submission_disposition(),
1628            SubmissionDisposition::NotSubmitted
1629        );
1630    }
1631
1632    #[test]
1633    fn response_metadata_requires_exact_echo_and_nonzero_canonical_diagnostic() {
1634        let correlation = [0x12; 16];
1635        let mut meta = lodestone_wire::ResponseMeta {
1636            format_version: u32::from(LODESTONE_API_RESPONSE_META_FORMAT_VERSION),
1637            request_id: "request-1".to_owned(),
1638            correlation_id: Identity {
1639                value: lower_hex(&correlation),
1640            },
1641            diagnostic_id: Identity { value: "34".repeat(16) },
1642        };
1643        assert!(response_meta_matches(&meta, "request-1", &correlation));
1644
1645        meta.correlation_id.value = "56".repeat(16);
1646        assert!(!response_meta_matches(&meta, "request-1", &correlation));
1647        meta.correlation_id.value = lower_hex(&correlation);
1648
1649        meta.diagnostic_id.value = "00".repeat(16);
1650        assert!(!response_meta_matches(&meta, "request-1", &correlation));
1651        meta.diagnostic_id.value = "AB".repeat(16);
1652        assert!(!response_meta_matches(&meta, "request-1", &correlation));
1653        meta.diagnostic_id.value = "34".repeat(16);
1654
1655        meta.request_id = "request-2".to_owned();
1656        assert!(!response_meta_matches(&meta, "request-1", &correlation));
1657        meta.request_id = "request-1".to_owned();
1658        meta.format_version = meta.format_version.saturating_add(1);
1659        assert!(!response_meta_matches(&meta, "request-1", &correlation));
1660    }
1661
1662    fn cache_key(collection: u128, principal: u128, credential: u128) -> VerifiedSessionCacheKey {
1663        VerifiedSessionCacheKey {
1664            collection_id: format!("{collection:032x}"),
1665            principal_id: format!("{principal:032x}"),
1666            profile_generation: "1".to_owned(),
1667            credential_id: format!("{credential:032x}"),
1668            credential_generation: "1".to_owned(),
1669            secret_reference: format!("memory://session/{collection}/{principal}/{credential}"),
1670        }
1671    }
1672
1673    fn verification(collection: u128, principal: u128) -> ProfileVerification {
1674        ProfileVerification {
1675            artifact_version: "development".to_owned(),
1676            principal_id: Identity {
1677                value: format!("{principal:032x}"),
1678            },
1679            collection_id: Identity {
1680                value: format!("{collection:032x}"),
1681            },
1682            policy_revision: U64 { value: "1".to_owned() },
1683            cluster_id: Identity {
1684                value: "00000000000000000000000000000001".to_owned(),
1685            },
1686            configuration_generation: U64 { value: "1".to_owned() },
1687            binding_revision: U64 { value: "1".to_owned() },
1688            schema_id: LODESTONE_API_SCHEMA_ID.to_owned(),
1689            schema_digest: lodestone_wire::Digest {
1690                value: LODESTONE_API_SPEC_SHA256.to_owned(),
1691            },
1692            native_endpoints: vec!["lodestone+tls://core.example:8443".to_owned()],
1693            selected_stream_endpoint: "https://core.example:9443".to_owned(),
1694            storage_profile: test_storage_profile(),
1695        }
1696    }
1697
1698    fn test_storage_profile() -> StorageProfileAttestation {
1699        StorageProfileAttestation {
1700            format_version: u32::from(LODESTONE_API_STORAGE_PROFILE_ATTESTATION_FORMAT_VERSION),
1701            configuration_generation: U64 { value: "1".to_owned() },
1702            overall_posture: lodestone_wire::StorageOverallPosture::FilesystemDevelopment,
1703            roles: vec![
1704                lodestone_wire::StorageProfileRoleBinding {
1705                    role: StorageProfileRole::Control,
1706                    authority_group: 0,
1707                    provider: lodestone_wire::StorageProviderPosture::FilesystemDevelopment,
1708                    service_class: lodestone_wire::StorageBackendServiceClass::RegionalObject,
1709                },
1710                lodestone_wire::StorageProfileRoleBinding {
1711                    role: StorageProfileRole::DefaultWal,
1712                    authority_group: 0,
1713                    provider: lodestone_wire::StorageProviderPosture::FilesystemDevelopment,
1714                    service_class: lodestone_wire::StorageBackendServiceClass::RegionalObject,
1715                },
1716                lodestone_wire::StorageProfileRoleBinding {
1717                    role: StorageProfileRole::DefaultData,
1718                    authority_group: 0,
1719                    provider: lodestone_wire::StorageProviderPosture::FilesystemDevelopment,
1720                    service_class: lodestone_wire::StorageBackendServiceClass::RegionalObject,
1721                },
1722            ],
1723            default_durability: lodestone_wire::ResourceDurabilityClass::RegionalObject,
1724            default_storage: lodestone_wire::ResourceStorageClass::Standard,
1725            default_policy_digest: lodestone_wire::Digest { value: "11".repeat(32) },
1726            storage_profile_digest: lodestone_wire::Digest { value: "22".repeat(32) },
1727            attestation_digest: lodestone_wire::Digest { value: "33".repeat(32) },
1728        }
1729    }
1730
1731    fn test_session_with_storage_expectation(
1732        posture: Option<lodestone_wire::StorageOverallPosture>,
1733        digest: Option<&str>,
1734    ) -> Result<CollectionSessionConfig, Box<dyn Error>> {
1735        let profile = crate::ValidatedConnectionProfile::new(lodestone_wire::ConnectionProfile {
1736            format_version: u32::from(lodestone_wire::LODESTONE_API_CONNECTION_PROFILE_FORMAT_VERSION),
1737            profile_generation: U64 { value: "1".to_owned() },
1738            native_endpoints: vec!["lodestone+tls://core.example:8443".to_owned()],
1739            selected_stream_endpoint: "https://core.example:9443".to_owned(),
1740            tls_server_name: "core.example".to_owned(),
1741            tls_trust_sha256: lodestone_wire::Digest { value: "44".repeat(32) },
1742            expected_cluster_id: Identity {
1743                value: "00000000000000000000000000000001".to_owned(),
1744            },
1745            expected_collection_id: Identity {
1746                value: "00000000000000000000000000000002".to_owned(),
1747            },
1748            expected_principal_id: Identity {
1749                value: "00000000000000000000000000000003".to_owned(),
1750            },
1751            schema_id: LODESTONE_API_SCHEMA_ID.to_owned(),
1752            schema_digest: lodestone_wire::Digest {
1753                value: LODESTONE_API_SPEC_SHA256.to_owned(),
1754            },
1755            credential_id: Identity {
1756                value: "00000000000000000000000000000004".to_owned(),
1757            },
1758            credential_generation: U64 { value: "1".to_owned() },
1759            credential_expires_at: None,
1760            secret_reference: "memory://storage-profile-test".to_owned(),
1761            storage_profile_expectation: Some(lodestone_wire::StorageProfileExpectation {
1762                format_version: u32::from(lodestone_wire::LODESTONE_API_STORAGE_PROFILE_EXPECTATION_FORMAT_VERSION),
1763                required_posture: posture,
1764                expected_storage_profile_digest: digest.map(|value| lodestone_wire::Digest {
1765                    value: value.to_owned(),
1766                }),
1767            }),
1768        })?;
1769        Ok(CollectionSessionConfig {
1770            profile,
1771            credentials: Arc::new(crate::FileCredentialProvider),
1772        })
1773    }
1774
1775    fn test_binding(storage_profile: StorageProfileAttestation) -> CoreBinding {
1776        CoreBinding {
1777            cluster_id: Identity {
1778                value: "00000000000000000000000000000001".to_owned(),
1779            },
1780            collection_id: Identity {
1781                value: "00000000000000000000000000000002".to_owned(),
1782            },
1783            configuration_generation: U64 { value: "1".to_owned() },
1784            native_endpoints: vec!["lodestone+tls://core.example:8443".to_owned()],
1785            selected_stream_endpoint: "https://core.example:9443".to_owned(),
1786            schema_id: LODESTONE_API_SCHEMA_ID.to_owned(),
1787            schema_digest: lodestone_wire::Digest {
1788                value: LODESTONE_API_SPEC_SHA256.to_owned(),
1789            },
1790            storage_profile,
1791        }
1792    }
1793
1794    #[test]
1795    fn storage_profile_expectations_fail_before_session_installation() -> Result<(), Box<dyn Error>> {
1796        let correct = test_session_with_storage_expectation(
1797            Some(lodestone_wire::StorageOverallPosture::FilesystemDevelopment),
1798            Some(&"22".repeat(32)),
1799        )?;
1800        let binding = test_binding(test_storage_profile());
1801        let revision = U64 { value: "1".to_owned() };
1802        validate_binding(&correct, &binding, true, &revision)?;
1803
1804        let wrong_posture = test_session_with_storage_expectation(
1805            Some(lodestone_wire::StorageOverallPosture::ProviderObjectStorage),
1806            None,
1807        )?;
1808        assert!(matches!(
1809            validate_binding(&wrong_posture, &binding, true, &revision),
1810            Err(ClientError::StorageProfileMismatch)
1811        ));
1812        assert_eq!(
1813            ClientError::StorageProfileMismatch.submission_disposition(),
1814            SubmissionDisposition::NotSubmitted
1815        );
1816
1817        let wrong_digest = test_session_with_storage_expectation(None, Some(&"55".repeat(32)))?;
1818        assert!(matches!(
1819            validate_binding(&wrong_digest, &binding, true, &revision),
1820            Err(ClientError::StorageProfileMismatch)
1821        ));
1822        Ok(())
1823    }
1824
1825    #[test]
1826    fn malformed_storage_profile_shape_is_an_identity_mismatch() -> Result<(), Box<dyn Error>> {
1827        let config = test_session_with_storage_expectation(
1828            Some(lodestone_wire::StorageOverallPosture::FilesystemDevelopment),
1829            None,
1830        )?;
1831        let revision = U64 { value: "1".to_owned() };
1832        let mut malformed = test_storage_profile();
1833        malformed.roles.swap(0, 1);
1834        assert!(matches!(
1835            validate_binding(&config, &test_binding(malformed), true, &revision),
1836            Err(ClientError::ProfileIdentityMismatch)
1837        ));
1838
1839        let mut malformed = test_storage_profile();
1840        malformed.roles[1].authority_group = 2;
1841        assert!(matches!(
1842            validate_binding(&config, &test_binding(malformed), true, &revision),
1843            Err(ClientError::ProfileIdentityMismatch)
1844        ));
1845
1846        let mut malformed = test_storage_profile();
1847        malformed.configuration_generation = U64 { value: "2".to_owned() };
1848        assert!(matches!(
1849            validate_binding(&config, &test_binding(malformed), true, &revision),
1850            Err(ClientError::ProfileIdentityMismatch)
1851        ));
1852        Ok(())
1853    }
1854
1855    #[test]
1856    fn automatic_retry_uses_only_typed_disposition_and_recovery() {
1857        let transport = |disposition| ClientError::Transport {
1858            kind: TransportErrorKind::Read,
1859            disposition,
1860        };
1861        assert!(transport(SubmissionDisposition::NotSubmitted).automatic_retry_admitted());
1862        assert!(!transport(SubmissionDisposition::Submitted).automatic_retry_admitted());
1863        assert!(!transport(SubmissionDisposition::Indeterminate).automatic_retry_admitted());
1864
1865        let server = |disposition, recovery| {
1866            ClientError::Server(ErrorBody {
1867                code: lodestone_wire::ErrorCode::TemporarilyUnavailable,
1868                message: "typed retry probe".to_owned(),
1869                disposition,
1870                recovery,
1871                details: lodestone_wire::ErrorDetail::TemporarilyUnavailable {
1872                    retry_after_millis: None,
1873                },
1874            })
1875        };
1876        assert!(server(ErrorDisposition::NotSubmitted, RecoveryAction::Retry).automatic_retry_admitted());
1877        assert!(server(ErrorDisposition::NotSubmitted, RecoveryAction::RetryAfter).automatic_retry_admitted());
1878        assert!(!server(ErrorDisposition::Indeterminate, RecoveryAction::Resolve).automatic_retry_admitted());
1879        assert!(!server(ErrorDisposition::NotSubmitted, RecoveryAction::Stop).automatic_retry_admitted());
1880    }
1881
1882    #[test]
1883    fn one_deadline_survives_connect_state_and_resolution_clones() -> Result<(), Box<dyn Error>> {
1884        let options = CallOptions::new(Duration::from_secs(5))?;
1885        let absolute = options.absolute_deadline_unix_millis()?;
1886        let local = options.local_deadline()?;
1887
1888        // These clones stand for discovery, binding, prepare, submit, and
1889        // resolution. None may derive a fresh duration from the current time.
1890        for stage in [
1891            options.clone(),
1892            options.clone(),
1893            options.clone(),
1894            options.clone(),
1895            options.for_resolution(),
1896        ] {
1897            assert_eq!(stage.absolute_deadline_unix_millis()?, absolute);
1898            assert_eq!(stage.local_deadline()?, local);
1899        }
1900
1901        let resolution = options.for_resolution();
1902        options.cancellation().cancel();
1903        assert!(options.cancellation().is_cancelled());
1904        assert!(!resolution.cancellation().is_cancelled());
1905        assert_eq!(resolution.absolute_deadline_unix_millis()?, absolute);
1906        Ok(())
1907    }
1908
1909    #[test]
1910    fn caller_supplied_absolute_deadline_is_preserved_exactly() -> Result<(), Box<dyn Error>> {
1911        let now = SystemTime::now().duration_since(UNIX_EPOCH)?;
1912        let now_millis = u64::try_from(now.as_millis())?;
1913        let absolute = now_millis.checked_add(5_000).ok_or("test deadline overflow")?;
1914        let options = CallOptions::from_absolute_deadline_unix_millis(absolute)?;
1915        assert_eq!(options.absolute_deadline_unix_millis()?, absolute);
1916
1917        let too_far = now_millis
1918            .checked_add(u64::try_from(MAX_CALL_TIMEOUT.as_millis())?)
1919            .and_then(|value| value.checked_add(1_000))
1920            .ok_or("test deadline overflow")?;
1921        assert!(matches!(
1922            CallOptions::from_absolute_deadline_unix_millis(too_far),
1923            Err(ClientError::InvalidCallOptions)
1924        ));
1925        assert_eq!(normalized_timeout(Duration::from_nanos(1))?, Duration::from_millis(1));
1926        Ok(())
1927    }
1928
1929    #[test]
1930    fn private_api_key_encodings_match_the_core_admission_contract() -> Result<(), Box<dyn Error>> {
1931        let material = CredentialMaterial::new(
1932            Identity {
1933                value: "00000000000000000000000000000001".to_owned(),
1934            },
1935            U64 { value: "1".to_owned() },
1936            SecretBytes::from_array([0; 32]),
1937        )?;
1938        assert_eq!(
1939            native_credential(&material)?.as_slice(),
1940            b"1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
1941        );
1942        assert_eq!(
1943            authorization_value(&material)?.as_slice(),
1944            b"ApiKey MTpBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB"
1945        );
1946        Ok(())
1947    }
1948
1949    #[test]
1950    fn typed_refusal_discards_the_connection_before_a_dependent_follow_up() {
1951        let refusal = Err(ClientError::Server(ErrorBody {
1952            code: lodestone_wire::ErrorCode::PermissionDenied,
1953            message: "denied".to_owned(),
1954            disposition: lodestone_wire::ErrorDisposition::NotSubmitted,
1955            recovery: lodestone_wire::RecoveryAction::Stop,
1956            details: lodestone_wire::ErrorDetail::PermissionDenied {
1957                action: None,
1958                workload_class: None,
1959            },
1960        }));
1961        assert!(!response_keeps_connection(&refusal));
1962        let failure = Err(ClientError::Transport {
1963            kind: TransportErrorKind::Read,
1964            disposition: SubmissionDisposition::Indeterminate,
1965        });
1966        assert!(!response_keeps_connection(&failure));
1967    }
1968
1969    #[test]
1970    fn connection_idle_age_is_fenced_below_the_gateway_prelude_timeout() {
1971        let now = tokio::time::Instant::now();
1972        assert!(connection_idle_is_reusable(now - MAX_REUSABLE_CONNECTION_IDLE, now));
1973        assert!(!connection_idle_is_reusable(
1974            now - MAX_REUSABLE_CONNECTION_IDLE - Duration::from_millis(1),
1975            now
1976        ));
1977    }
1978
1979    #[test]
1980    fn verified_session_cache_is_bounded_and_never_crosses_principals() {
1981        let metrics = CellTransportMetrics::default();
1982        let mut cache = VerifiedSessionCache::default();
1983        let first = cache_key(1, 10, 100);
1984        let other_principal = cache_key(1, 11, 100);
1985        assert_ne!(first, other_principal);
1986        cache.insert(first.clone(), verification(1, 10), &metrics);
1987        assert!(cache.get(&other_principal, &metrics).is_none());
1988        assert_eq!(cache.get(&first, &metrics), Some(verification(1, 10)));
1989
1990        for offset in 0..=MAX_VERIFIED_SESSION_METADATA {
1991            let identity = u128::try_from(offset).unwrap_or_default().saturating_add(1_000);
1992            cache.insert(
1993                cache_key(identity, identity, identity),
1994                verification(identity, identity),
1995                &metrics,
1996            );
1997        }
1998        assert_eq!(cache.entries.len(), MAX_VERIFIED_SESSION_METADATA);
1999        assert!(metrics.session_cache_evictions.load(Ordering::Relaxed) > 0);
2000        assert_eq!(
2001            metrics.session_cache_entries.load(Ordering::Relaxed),
2002            MAX_VERIFIED_SESSION_METADATA
2003        );
2004        assert_eq!(metrics.session_cache_hits.load(Ordering::Relaxed), 1);
2005        assert_eq!(metrics.session_cache_misses.load(Ordering::Relaxed), 1);
2006    }
2007
2008    #[test]
2009    fn generated_multi_index_scope_requires_a_nonempty_distinct_set() {
2010        fn index(name: &str, incarnation: &str) -> Resource {
2011            Resource {
2012                kind: "index".to_owned(),
2013                name: name.to_owned(),
2014                incarnation: Identity {
2015                    value: incarnation.to_owned(),
2016                },
2017            }
2018        }
2019
2020        fn request(indexes: Vec<Resource>) -> RequestBody {
2021            RequestBody::OpenPit(OpenPitRequest {
2022                indexes,
2023                keep_alive_millis: U64 {
2024                    value: "1000".to_owned(),
2025                },
2026            })
2027        }
2028
2029        let first = index("orders", "00000000000000000000000000000001");
2030        let second = index("events", "00000000000000000000000000000002");
2031        assert!(matches!(
2032            resolve_index_name(&request(Vec::new()), None),
2033            Err(ClientError::CollectionIndexRefRequired)
2034        ));
2035        assert!(matches!(
2036            resolve_index_name(&request(vec![first.clone(), second.clone()]), None),
2037            Ok(None)
2038        ));
2039        assert!(matches!(
2040            resolve_index_name(&request(vec![first.clone(), first.clone()]), None),
2041            Err(ClientError::CollectionIndexRefMismatch)
2042        ));
2043        assert!(matches!(
2044            resolve_index_name(&request(vec![second.clone(), first.clone(), first]), None),
2045            Err(ClientError::CollectionIndexRefMismatch)
2046        ));
2047    }
2048
2049    #[test]
2050    fn generated_get_index_is_collection_scoped() {
2051        let request = RequestBody::GetIndex(GetIndexRequest {
2052            name: "events".to_owned(),
2053            expected_incarnation: None,
2054        });
2055
2056        assert!(matches!(resolve_index_name(&request, None), Ok(None)));
2057        assert!(matches!(
2058            resolve_index_name(&request, Some("events")),
2059            Err(ClientError::CollectionIndexRefMismatch)
2060        ));
2061    }
2062}