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