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