Skip to main content

rustev_remote_http/
client.rs

1//! The `rustev.remote/1` HTTP client (spec 009, 3.11): a `DecisionBackend`
2//! that reaches a backend in another process and conforms to Part A.
3//!
4//! Capabilities are negotiated once, at construction (3.2.1). Each attempt
5//! id is submitted at most once and is the idempotency key; nothing is
6//! retried, redirected or substituted (3.3.1, I-3). An attempt has one total
7//! budget (3.4.1). Cancellation acknowledges `stopped` only when no request
8//! byte left the process, or the remote side confirmed the stop (3.4.3).
9//! Answers are mapped to `RawOutput` as received, never repaired (3.5).
10//! Every exchange yields a bounded record for the host's sink (3.9).
11//!
12//! Shared-state batching (3.10.4) is off unless both the host configures
13//! [`BatchConfig`] and the remote terms offer `shared_state`.
14
15use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
16use std::fmt;
17use std::sync::{Arc, Mutex};
18use std::time::Duration;
19
20use rustev_contract::canonical::canonical_value_bytes;
21use rustev_contract::definition::{Determinism, Operation};
22use rustev_contract::descriptor::{BackendDescriptor, Isolation, OutputKind};
23use rustev_contract::ids::{ArtifactId, DescriptorId};
24use rustev_contract::limits::REMOTE_V1;
25use rustev_contract::output::RawOutput;
26use rustev_contract::remote::{
27    Attribution, Batching, CancelRequest, CancelResponse, CancelSupport, DescribeRequest,
28    DescribeResponse, Disclosure, ExchangeMember, HostedBackend, InferItem, InferRequest,
29    InferResponse, ItemResult, MAX_ITEMS, MemberOutcome, PROTOCOL_MAJOR, RemoteCancelAnswer,
30    RemoteCode, RemoteTerms, ServedIdentity, WirePrincipal, protocol_name,
31};
32use rustev_contract::run::{
33    BatchMembership, Charge, ChargeProvenance, CostBound, CostModel, EvidenceUnavailable,
34    ExchangeReference, RemoteEvidence, RemoteState, RetentionClass,
35};
36use rustev_contract::{Document, Identified, schema};
37use rustev_core::seams::{
38    AdapterFailure, AttemptCall, AttemptReport, BatchAttemptCall, BatchAttemptReport,
39    BatchCapability, BatchMemberReport, BoxFuture, CancelAck, CancelSignal, DecisionBackend,
40    RemoteEnd,
41};
42use serde::Deserialize;
43use tokio::sync::oneshot;
44
45use crate::budget::{Budget, TimeoutBeyondBudget, check_transport_timeout};
46use crate::cancel::{cancel_outcome, cancelled_report};
47use crate::cost::{UnitRate, attribute, convert_bound, convert_charge};
48use crate::exchange::{
49    ExchangeSink, ExchangeStats, Exchanges, ReconciliationOffer, RetainedBytes, blank_record,
50    output_digest,
51};
52use crate::http::{
53    Endpoint, EndpointError, HttpPool, HttpReply, HttpTransport, Scheme, Secret, TransportError,
54};
55use crate::identity::{AdapterBinding, served_identity};
56use crate::phases::TransportPhases;
57use crate::pool::{KeyMaterial, POOL_KEY_SCHEMA, PoolBinding, credential_identity};
58use crate::principal::{EndpointOperator, PrincipalPosture, PrincipalWire};
59use crate::server::{CANCEL_PATH, DESCRIBE_PATH, INFER_PATH};
60use crate::taxonomy::{RemoteFailure, classify_status, is_local_only};
61use crate::wire::{SentTracker, digest_bytes};
62
63/// The version of the identity mapping this client applies: answers are
64/// supplied as received, with no reshaping (3.5.4).
65pub const MAPPING_VERSION: &str = "rustev.remote/1 identity mapping";
66
67/// Adapter-scoped batching (3.10.4). Enable only after the batched path is
68/// qualified for the adapter.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub struct BatchConfig {
71    /// How long a dispatched attempt may wait for siblings; never past its
72    /// budget.
73    pub window_ms: u64,
74    /// At most this many items per exchange (also capped by the terms and
75    /// by [`MAX_ITEMS`]).
76    pub max_items: u32,
77}
78
79/// Host configuration. Credentials, addresses, rates and concurrency are
80/// configuration; only [`ClientConfig::endpoint_class`], the privacy
81/// options and the negotiated remote identity enter the artifact identity.
82#[derive(Clone)]
83pub struct ClientConfig {
84    /// `http[s]://host:port[/base]`; plain `http` only for loopback.
85    pub endpoint: String,
86    /// The hosted backend to bind.
87    pub backend_id: String,
88    /// Names the endpoint in the identity; never a secret URL or key.
89    pub endpoint_class: String,
90    pub bearer: Option<Secret>,
91    /// Overrides the default TLS configuration (webpki roots).
92    pub tls: Option<Arc<rustls::ClientConfig>>,
93    /// The budget of negotiation at construction.
94    pub setup_budget_ms: u64,
95    /// A transport limit shorter than the budget, if any. Longer than the
96    /// setup budget is refused at construction (section 6).
97    pub transport_timeout_ms: Option<u64>,
98    /// Subtracted from the budget the remote side is told (3.4.1).
99    pub transit_margin_ms: u64,
100    /// Deployment units per remote unit (3.8.1).
101    pub unit_rate: UnitRate,
102    /// Sent on every request and recorded as requested (3.8.2).
103    pub privacy: BTreeMap<String, String>,
104    /// Off by default.
105    pub batching: Option<BatchConfig>,
106    /// Retain request and response bytes for the sink (R-19: off).
107    pub retain_bytes: bool,
108    /// When the terms confirm cancellation, wait (within the attempt's
109    /// budget) for the confirmation before answering the signal. The
110    /// runtime polls a cancelled attempt once, so with this on it records
111    /// the answer as not observed; off by default.
112    pub await_cancel_confirmation: bool,
113    /// The budget of a cancel exchange sent after the attempt answered.
114    pub cancel_budget_ms: u64,
115    /// Phase ceilings inside each attempt's one budget (spec 020, 3.4).
116    /// Disabled by default; enabling it changes the binding identity.
117    pub phases: TransportPhases,
118    /// The protocol major version this binding negotiates, exactly: 1 (the
119    /// default) or 2 (spec 020, 2). No per-call upgrade or downgrade.
120    pub protocol_major: u32,
121    /// Who operates the endpoint; third party by default (spec 020, 3.6).
122    pub operator: EndpointOperator,
123    /// The principal wire posture; omitted by default (spec 020, 3.6).
124    pub principal: PrincipalPosture,
125    /// Reuse connections from this host-owned pool (spec 020, 3.5); none
126    /// by default, so every exchange opens its own connection. Clients
127    /// sharing a pool share connections only under an exact isolation key.
128    pub pool: Option<Arc<HttpPool>>,
129    /// The host's nonsecret label for its own `tls` configuration, an
130    /// isolation-key member. With a custom configuration and no label the
131    /// key is unknown and no connection is reused.
132    pub tls_label: Option<String>,
133    /// Accept runtime-planned batches of at most this many members (spec
134    /// 020, 3.3), when the remote terms offer shared-state batching. Off by
135    /// default; the runtime still batches only under an enabled spec 017
136    /// policy. Enabling it changes the binding identity.
137    pub batch_dispatch: Option<u32>,
138    /// The evidence sink accepts `rustev.run/2` (spec 020, 3.1 and 3.2):
139    /// keep each attempt's remote evidence for the runtime to take. Off by
140    /// default; enabling it changes the binding identity.
141    pub run_evidence: bool,
142}
143
144impl ClientConfig {
145    pub fn new(
146        endpoint: impl Into<String>,
147        backend_id: impl Into<String>,
148        endpoint_class: impl Into<String>,
149    ) -> Self {
150        ClientConfig {
151            endpoint: endpoint.into(),
152            backend_id: backend_id.into(),
153            endpoint_class: endpoint_class.into(),
154            bearer: None,
155            tls: None,
156            setup_budget_ms: 5_000,
157            transport_timeout_ms: None,
158            transit_margin_ms: 20,
159            unit_rate: UnitRate::ONE,
160            privacy: BTreeMap::new(),
161            batching: None,
162            retain_bytes: false,
163            await_cancel_confirmation: false,
164            cancel_budget_ms: 1_000,
165            phases: TransportPhases::Disabled,
166            protocol_major: PROTOCOL_MAJOR,
167            operator: EndpointOperator::ThirdParty,
168            principal: PrincipalPosture::Omitted,
169            pool: None,
170            tls_label: None,
171            run_evidence: false,
172            batch_dispatch: None,
173        }
174    }
175}
176
177/// Why construction failed. Nothing is retried.
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub enum SetupError {
180    Endpoint(EndpointError),
181    TimeoutBeyondBudget(TimeoutBeyondBudget),
182    /// Negotiation failed with this code.
183    Remote {
184        code: RemoteCode,
185        detail: String,
186    },
187    NoCommonVersion(u32),
188    NoSuchBackend(String),
189    /// The descriptor does not match its stated identity.
190    Identity(String),
191    /// The terms are unusable or refuse the configuration.
192    Terms(String),
193    /// The host configuration is incomplete or inconsistent (spec 020,
194    /// 3.1); refused before any network use.
195    Config(String),
196}
197
198impl fmt::Display for SetupError {
199    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
200        match self {
201            SetupError::Endpoint(e) => write!(f, "{e}"),
202            SetupError::TimeoutBeyondBudget(e) => write!(f, "{e}"),
203            SetupError::Remote { code, detail } => {
204                write!(f, "negotiation failed: remote:{}: {detail}", code.as_str())
205            }
206            SetupError::NoCommonVersion(v) => write!(f, "remote chose protocol version {v}"),
207            SetupError::NoSuchBackend(b) => write!(f, "the remote side hosts no `{b}`"),
208            SetupError::Identity(s) => write!(f, "descriptor identity: {s}"),
209            SetupError::Terms(s) => write!(f, "remote terms: {s}"),
210            SetupError::Config(s) => write!(f, "configuration: {s}"),
211        }
212    }
213}
214
215impl std::error::Error for SetupError {}
216
217/// Attempt ids submitted recently: a second submission is refused locally.
218struct RecentIds {
219    set: HashSet<String>,
220    order: VecDeque<String>,
221}
222
223const RECENT_IDS: usize = 65_536;
224
225impl RecentIds {
226    fn insert(&mut self, id: &str) -> bool {
227        if self.set.contains(id) {
228            return false;
229        }
230        if self.order.len() >= RECENT_IDS
231            && let Some(old) = self.order.pop_front()
232        {
233            self.set.remove(&old);
234        }
235        self.set.insert(id.to_string());
236        self.order.push_back(id.to_string());
237        true
238    }
239}
240
241#[derive(Debug, Clone, Copy, PartialEq, Eq)]
242enum Status {
243    Waiting,
244    /// Left before the request was built: never sent.
245    Withdrawn,
246    /// Stopped waiting after the request was built.
247    Abandoned,
248    Delivered,
249}
250
251struct Member {
252    attempt_id: String,
253    projection: String,
254    kind: OutputKind,
255    budget: Budget,
256    status: Status,
257    tx: Option<oneshot::Sender<AttemptReport>>,
258    /// A charge was supplied or offered for it; offer nothing more.
259    settled: bool,
260    /// A final charge the remote side confirmed on cancel, in deployment
261    /// units. Offered only when the exchange brought no response, whose
262    /// charge would otherwise already count it.
263    confirmed: Option<Charge>,
264    /// The member is waiting for a cancel confirmation: a late share is
265    /// parked in `late_share` for it to decide on, never offered here.
266    awaiting: bool,
267    late_share: Option<u64>,
268}
269
270#[derive(Debug, Clone, Copy, PartialEq, Eq)]
271enum Phase {
272    Collecting,
273    Sending,
274}
275
276struct ExState {
277    phase: Phase,
278    members: Vec<Member>,
279    flush: Option<oneshot::Sender<()>>,
280    /// Set when the exchange ended: whether a response came back.
281    finished: Option<bool>,
282}
283
284struct Exchange {
285    state: Mutex<ExState>,
286    sent: SentTracker,
287    trace_id: String,
288    key: Option<(String, String)>,
289    /// The principal reference every member of the exchange carries.
290    principal: Option<WirePrincipal>,
291    /// A runtime-planned batch (spec 020, 3.3): its id, and the members
292    /// keep the order the runtime gave them.
293    batch_id: Option<String>,
294}
295
296struct Inner {
297    transport: HttpTransport,
298    config: ClientConfig,
299    remote: HostedBackend,
300    descriptor: BackendDescriptor,
301    binding: ArtifactId,
302    exchanges: Exchanges,
303    recent: Mutex<RecentIds>,
304    open: Mutex<HashMap<(String, String), Arc<Exchange>>>,
305    max_items: usize,
306    /// The negotiated protocol major version and its name.
307    version: u32,
308    protocol: &'static str,
309    /// Remote evidence per attempt, for a `rustev.run/2` record.
310    evidence: Mutex<EvidenceStore>,
311    /// The most members of a runtime-planned batch: the binding's bound
312    /// cut to the negotiated `max_items`; `None` when not offered.
313    batch_max: Option<usize>,
314}
315
316/// Remote evidence kept for the runtime to take, at most
317/// [`EVIDENCE_KEPT`] attempts (the oldest go first).
318#[derive(Default)]
319struct EvidenceStore {
320    map: HashMap<String, RemoteEvidence>,
321    order: VecDeque<String>,
322}
323
324const EVIDENCE_KEPT: usize = 4_096;
325
326impl EvidenceStore {
327    fn put(&mut self, e: RemoteEvidence) {
328        if !self.map.contains_key(&e.attempt_id) {
329            if self.order.len() >= EVIDENCE_KEPT
330                && let Some(old) = self.order.pop_front()
331            {
332                self.map.remove(&old);
333            }
334            self.order.push_back(e.attempt_id.clone());
335        }
336        self.map.insert(e.attempt_id.clone(), e);
337    }
338
339    fn take(&mut self, attempt_id: &str) -> Option<RemoteEvidence> {
340        let e = self.map.remove(attempt_id)?;
341        self.order.retain(|x| x != attempt_id);
342        Some(e)
343    }
344}
345
346/// An attempt's evidence before its exchange says more.
347fn evidence(
348    inner: &Inner,
349    attempt_id: &str,
350    principal: Option<&WirePrincipal>,
351    reason: EvidenceUnavailable,
352) -> RemoteEvidence {
353    RemoteEvidence {
354        attempt_id: attempt_id.to_string(),
355        step: String::new(),
356        instance: vec![],
357        exchange: ExchangeReference::Unavailable { reason },
358        requested_identity: None,
359        served: ServedIdentity::unknown(),
360        binding: inner.binding.clone(),
361        protocol: inner.protocol.into(),
362        endpoint_class: inner.config.endpoint_class.clone(),
363        code: None,
364        remote_state: RemoteState::Finished,
365        charge_provenance: ChargeProvenance::Unknown,
366        batch: None,
367        principal_digest: principal.map(|p| p.evidence().digest),
368    }
369}
370
371/// A `DecisionBackend` over `rustev.remote/1`. Clones share state.
372#[derive(Clone)]
373pub struct RemoteClient {
374    inner: Arc<Inner>,
375}
376
377fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
378    m.lock().unwrap_or_else(|e| e.into_inner())
379}
380
381fn setup_failure(code: RemoteCode, detail: impl Into<String>) -> SetupError {
382    SetupError::Remote {
383        code,
384        detail: detail.into(),
385    }
386}
387
388fn transport_code(e: &TransportError) -> (RemoteCode, String) {
389    match e {
390        TransportError::Unsent(d) => (RemoteCode::TransportUnsent, d.clone()),
391        TransportError::Interrupted(d) => (RemoteCode::TransportInterrupted, d.clone()),
392        TransportError::TooLarge { limit } => (
393            RemoteCode::MalformedResponse,
394            format!("response exceeds {limit} bytes"),
395        ),
396        TransportError::Expired { sent: false } => {
397            (RemoteCode::TransportUnsent, "the budget ran out".into())
398        }
399        TransportError::Expired { sent: true } => (
400            RemoteCode::TransportInterrupted,
401            "the budget ran out before a complete response".into(),
402        ),
403        TransportError::PhaseExpired(p) => (
404            p.code,
405            format!(
406                "the {:?} phase ran out after {} ms of its {} ms allowance",
407                p.phase, p.elapsed_ms, p.allowance_ms
408            ),
409        ),
410    }
411}
412
413/// Whether a transport error is the attempt budget running out, rather than
414/// a shorter limit: the members whose own budget ran out then answer their
415/// own signals.
416fn budget_ran_out(e: &TransportError, budget: &Budget) -> bool {
417    match e {
418        TransportError::Expired { .. } => budget.expired(),
419        TransportError::PhaseExpired(p) => {
420            p.limit == rustev_contract::remote::PhaseLimit::Budget && budget.expired()
421        }
422        _ => false,
423    }
424}
425
426fn validate_terms(t: &RemoteTerms) -> Result<(), SetupError> {
427    let bad = |s: &str| Err(SetupError::Terms(s.to_string()));
428    if (t.served.disclosure == Disclosure::Pinned) != t.served.identity.is_some() {
429        return bad("a served identity is given exactly when it is pinned");
430    }
431    if t.max_request_bytes == 0 || t.max_response_bytes == 0 {
432        return bad("zero request or response limit");
433    }
434    if let Batching::SharedState { max_items: 0 } = t.batching {
435        return bad("shared_state with max_items 0");
436    }
437    Ok(())
438}
439
440impl RemoteClient {
441    /// Construct the adapter: check the configuration, negotiate the
442    /// protocol version, and obtain the remote descriptor and terms. The
443    /// descriptor this adapter then returns is fixed for its lifetime.
444    pub async fn connect(
445        config: ClientConfig,
446        sink: Arc<dyn ExchangeSink>,
447    ) -> Result<RemoteClient, SetupError> {
448        check_transport_timeout(config.transport_timeout_ms, config.setup_budget_ms)
449            .map_err(SetupError::TimeoutBeyondBudget)?;
450        config
451            .phases
452            .validate()
453            .map_err(|e| SetupError::Config(e.to_string()))?;
454        let protocol = protocol_name(config.protocol_major).ok_or_else(|| {
455            SetupError::Config(format!("no protocol version {}", config.protocol_major))
456        })?;
457        let principal_wire = config
458            .principal
459            .validate(config.operator, config.protocol_major)
460            .map_err(|e| SetupError::Config(e.to_string()))?;
461        if config
462            .batch_dispatch
463            .is_some_and(|m| m < 2 || m as usize > MAX_ITEMS)
464        {
465            return Err(SetupError::Config(format!(
466                "a runtime-planned batch has 2 to {MAX_ITEMS} members"
467            )));
468        }
469        let endpoint = Endpoint::parse(&config.endpoint).map_err(SetupError::Endpoint)?;
470        let transport = HttpTransport::new(
471            endpoint,
472            config.tls.clone(),
473            config.bearer.clone(),
474            config.transport_timeout_ms.map(Duration::from_millis),
475        )
476        .map_err(SetupError::Endpoint)?
477        .with_phases(config.phases);
478        let body = DescribeRequest {
479            schema: schema::REMOTE_DESCRIBE.into(),
480            versions: vec![config.protocol_major],
481        }
482        .canonical()
483        .map_err(|e| SetupError::Terms(e.to_string()))?;
484        let budget = Budget::from_ms(config.setup_budget_ms);
485        let reply = transport
486            .post(
487                DESCRIBE_PATH,
488                body,
489                REMOTE_V1.max_bytes,
490                &budget,
491                &SentTracker::new(),
492            )
493            .await
494            .map_err(|e| {
495                let (code, detail) = transport_code(&e);
496                setup_failure(code, detail)
497            })?;
498        if let Some(code) = classify_status(reply.status) {
499            return Err(setup_failure(code, format!("HTTP {}", reply.status)));
500        }
501        let described = DescribeResponse::parse(&reply.body)
502            .map_err(|e| setup_failure(RemoteCode::MalformedResponse, e.to_string()))?;
503        if described.version != config.protocol_major {
504            return Err(SetupError::NoCommonVersion(described.version));
505        }
506        let remote = described
507            .backends
508            .into_iter()
509            .find(|b| b.descriptor.backend_id == config.backend_id)
510            .ok_or_else(|| SetupError::NoSuchBackend(config.backend_id.clone()))?;
511        let id = remote
512            .descriptor
513            .id()
514            .map_err(|e| SetupError::Identity(e.to_string()))?;
515        if id != remote.descriptor_id {
516            return Err(SetupError::Identity(
517                "the descriptor does not hash to its stated identity".into(),
518            ));
519        }
520        validate_terms(&remote.terms)?;
521        if let Some(p) = config
522            .privacy
523            .keys()
524            .find(|k| !remote.terms.privacy.contains(k))
525        {
526            return Err(SetupError::Terms(format!(
527                "privacy option `{p}` is not supported"
528            )));
529        }
530        let binding = AdapterBinding {
531            protocol: protocol.into(),
532            endpoint_class: config.endpoint_class.clone(),
533            remote_artifact: Some(remote.descriptor.artifact.clone()),
534            remote_descriptor: Some(remote.descriptor_id.clone()),
535            requested_model: None,
536            model_pin: remote.terms.served.identity.clone(),
537            mapping_version: MAPPING_VERSION.into(),
538            mapping_tables: BTreeMap::new(),
539            routing_privacy: config.privacy.clone(),
540            max_request_bytes: remote.terms.max_request_bytes,
541            max_response_bytes: remote.terms.max_response_bytes,
542            transport_phases: config.phases,
543            principal_wire: principal_wire.clone(),
544            run_record_schema: config
545                .run_evidence
546                .then(|| rustev_contract::schema::RUN_V2.to_string()),
547            batch_dispatch_max_members: config.batch_dispatch,
548            connection_pool: config
549                .pool
550                .as_ref()
551                .map(|p| p.binding())
552                .unwrap_or(PoolBinding::Disabled),
553        }
554        .artifact()
555        .map_err(|e| SetupError::Identity(e.to_string()))?;
556        // Exchanges after negotiation may reuse connections; negotiation
557        // itself used a fresh one.
558        let transport = match &config.pool {
559            None => transport,
560            Some(pool) => {
561                let endpoint = transport.endpoint().clone();
562                let tls = match (endpoint.scheme, &config.tls, &config.tls_label) {
563                    (Scheme::Http, _, _) => Some("plaintext".to_string()),
564                    (Scheme::Https, None, _) => Some("webpki-roots".to_string()),
565                    (Scheme::Https, Some(_), label) => label.clone(),
566                };
567                let material = KeyMaterial {
568                    schema: POOL_KEY_SCHEMA.into(),
569                    endpoint_class: config.endpoint_class.clone(),
570                    authority: format!(
571                        "{}://{}:{}",
572                        match endpoint.scheme {
573                            Scheme::Http => "http",
574                            Scheme::Https => "https",
575                        },
576                        endpoint.host,
577                        endpoint.port
578                    ),
579                    protocol: format!("{protocol} http/1.1"),
580                    proxy_route: "direct".into(),
581                    tls,
582                    credential: credential_identity(config.bearer.as_ref().map(|s| s.expose())),
583                    privacy: config.privacy.clone(),
584                    principal_posture: match &principal_wire {
585                        PrincipalWire::Omitted => "omitted".into(),
586                        PrincipalWire::HostOperated {
587                            mapping_version,
588                            scope,
589                        } => format!("host_operated {mapping_version} {scope}"),
590                    },
591                    principal: None,
592                    binding: Some(binding.clone()),
593                };
594                transport.with_pool(pool.clone(), material)
595            }
596        };
597        // A remote adapter cannot prove any determinism across a network
598        // and a remote side it does not control (3.2.2).
599        let descriptor = BackendDescriptor {
600            artifact: binding.clone(),
601            determinism: Determinism::Unspecified,
602            ..remote.descriptor.clone()
603        };
604        let max_items = match (config.batching, remote.terms.batching) {
605            (Some(b), Batching::SharedState { max_items }) => {
606                (b.max_items.min(max_items) as usize).clamp(1, MAX_ITEMS)
607            }
608            _ => 1,
609        };
610        // Negotiated terms never enable what the binding disabled nor raise
611        // its bound (spec 020, 3.1).
612        let batch_max = match (config.batch_dispatch, remote.terms.batching) {
613            (Some(m), Batching::SharedState { max_items }) => {
614                Some((m.min(max_items) as usize).min(MAX_ITEMS)).filter(|n| *n >= 2)
615            }
616            _ => None,
617        };
618        let exchanges = Exchanges::new(sink, config.retain_bytes);
619        let config_version = config.protocol_major;
620        Ok(RemoteClient {
621            inner: Arc::new(Inner {
622                transport,
623                config,
624                remote,
625                descriptor,
626                binding,
627                exchanges,
628                recent: Mutex::new(RecentIds {
629                    set: HashSet::new(),
630                    order: VecDeque::new(),
631                }),
632                open: Mutex::new(HashMap::new()),
633                max_items,
634                version: config_version,
635                protocol,
636                evidence: Mutex::new(EvidenceStore::default()),
637                batch_max,
638            }),
639        })
640    }
641
642    /// The negotiated terms.
643    pub fn terms(&self) -> &RemoteTerms {
644        &self.inner.remote.terms
645    }
646
647    /// The remote backend's own descriptor and its identity, as negotiated.
648    pub fn remote_descriptor(&self) -> (&BackendDescriptor, &DescriptorId) {
649        (
650            &self.inner.remote.descriptor,
651            &self.inner.remote.descriptor_id,
652        )
653    }
654
655    /// The adapter binding identity: this adapter's artifact.
656    pub fn binding(&self) -> &ArtifactId {
657        &self.inner.binding
658    }
659
660    /// Items per exchange: 1 unless batching is on and offered.
661    pub fn max_items(&self) -> usize {
662        self.inner.max_items
663    }
664
665    pub fn exchange_stats(&self) -> ExchangeStats {
666        self.inner.exchanges.stats()
667    }
668}
669
670#[derive(Deserialize)]
671struct ProjectionHead {
672    operation: Operation,
673    #[serde(default)]
674    instance: serde_json::Value,
675    #[serde(default)]
676    values: serde_json::Value,
677}
678
679fn output_kind_of(o: &RawOutput) -> Option<OutputKind> {
680    match o {
681        // The remote generation mapping is not defined by spec 037.
682        RawOutput::TextRecord(_) | RawOutput::TypedRecord { .. } => None,
683        RawOutput::Screen { .. } => Some(OutputKind::Screen),
684        RawOutput::Label(_) => Some(OutputKind::Label),
685        RawOutput::Distribution(_) => Some(OutputKind::Distribution),
686        RawOutput::Logits(_) => Some(OutputKind::Logits),
687        RawOutput::Scores(_) => Some(OutputKind::Scores),
688    }
689}
690
691impl DecisionBackend for RemoteClient {
692    fn descriptor(&self) -> &BackendDescriptor {
693        &self.inner.descriptor
694    }
695
696    /// `bounded` only when the remote side enforces a per-call maximum it
697    /// declared (3.8.1).
698    fn cost_model(&self) -> CostModel {
699        match self.inner.remote.terms.cost {
700            CostBound::Bounded { .. } => CostModel::Bounded,
701            CostBound::Estimated { .. } => CostModel::Estimated,
702            CostBound::Unknown => CostModel::Unknown,
703        }
704    }
705
706    fn cost_bound(&self, _projection: &[u8]) -> CostBound {
707        convert_bound(self.inner.remote.terms.cost, self.inner.config.unit_rate)
708    }
709
710    fn infer<'a>(&'a self, call: AttemptCall<'a>) -> BoxFuture<'a, AttemptReport> {
711        let inner = self.inner.clone();
712        let attempt_id = call.attempt_id.to_string();
713        let signal = call.cancel.clone();
714        let budget = Budget::start(call.cx.remaining_ms);
715        let trace_id = call.cx.trace_id.clone();
716        let projection = call.projection.to_vec();
717        // Resolved now; the handle itself never leaves this call.
718        let principal = inner
719            .config
720            .principal
721            .for_attempt(&call.cx.principal_handle);
722        Box::pin(async move {
723            let Ok(principal) = principal else {
724                // Required context is absent: refused before any byte.
725                let report = unsent_failure(
726                    RemoteCode::MalformedRequest,
727                    "no principal reference within its bounds; not sent",
728                );
729                note_unexchanged(&inner, &attempt_id, None, &report);
730                return report;
731            };
732            let p = principal.clone();
733            let report = attempt(
734                inner.clone(),
735                attempt_id.clone(),
736                projection,
737                trace_id,
738                principal,
739                budget,
740                signal,
741            )
742            .await;
743            note_unexchanged(&inner, &attempt_id, p.as_ref(), &report);
744            report
745        })
746    }
747
748    fn take_remote_evidence(&self, attempt_id: &str) -> Option<RemoteEvidence> {
749        lock(&self.inner.evidence).take(attempt_id)
750    }
751
752    /// Offered only when the binding enables runtime-planned batches and the
753    /// negotiated terms declare shared-state batching (spec 020, 3.3).
754    /// Any per-item operation disables batching for the entire backend
755    /// (spec 035, 3.15.3); the projected operation must also be declared.
756    /// Equal compatibility means the same binding and the same shared state
757    /// (`instance` and `values`), which is what one request may carry.
758    fn batch_capability(&self, projection: &[u8]) -> Option<BatchCapability> {
759        self.inner.batch_max?;
760        if self
761            .inner
762            .descriptor
763            .operations
764            .iter()
765            .any(|o| o.screen.is_some() && o.isolation == Some(Isolation::PerItem))
766        {
767            return None;
768        }
769        let head =
770            rustev_contract::bounded::parse_bounded::<ProjectionHead>(projection, &REMOTE_V1)
771                .ok()?;
772        self.inner
773            .descriptor
774            .operations
775            .iter()
776            .any(|o| o.operation == head.operation)
777            .then_some(())?;
778        let state = canonical_value_bytes(&serde_json::json!({
779            "binding": self.inner.binding.as_str(),
780            "instance": head.instance,
781            "values": head.values,
782        }))
783        .ok()?;
784        Some(BatchCapability {
785            compatibility: digest_bytes(&state),
786            work_units: 1,
787        })
788    }
789
790    fn infer_batch<'a>(&'a self, call: BatchAttemptCall) -> BoxFuture<'a, BatchAttemptReport> {
791        let inner = self.inner.clone();
792        Box::pin(async move { batch(inner, call).await })
793    }
794}
795
796/// A whole-batch refusal before any byte was sent.
797fn batch_refused(failure: AdapterFailure, code: RemoteCode, detail: &str) -> BatchAttemptReport {
798    BatchAttemptReport {
799        result: Err((failure, crate::taxonomy::failure_detail(code, detail))),
800        charge: Charge::Observed { units: 0 },
801        cancel: CancelAck::NotRequested,
802        remote: RemoteEnd::Finished,
803    }
804}
805
806/// One runtime-planned batch (spec 020, 3.3): every member checked as an
807/// independent attempt would be, the dispatchable ones sent in one exchange
808/// in the runtime's order, each answered on its own (its output, failure,
809/// cancellation and remote state), never filled from a sibling. A member
810/// cancelled before the first request byte is removed and reported unsent.
811async fn batch(inner: Arc<Inner>, call: BatchAttemptCall) -> BatchAttemptReport {
812    let dispatch_start = tokio::time::Instant::now();
813    let Some(max) = inner.batch_max else {
814        return batch_refused(
815            AdapterFailure::Permanent,
816            RemoteCode::Capability,
817            "runtime-planned batches are not enabled",
818        );
819    };
820    if call.members.is_empty() || call.members.len() > max {
821        return batch_refused(
822            AdapterFailure::Permanent,
823            RemoteCode::Capability,
824            "the batch exceeds the negotiated bound",
825        );
826    }
827    // Apply the backend-wide server rule even when callers bypass
828    // batch_capability (spec 035, 3.15.3).
829    if call.members.len() > 1
830        && inner
831            .descriptor
832            .operations
833            .iter()
834            .any(|o| o.screen.is_some() && o.isolation == Some(Isolation::PerItem))
835    {
836        return batch_refused(
837            AdapterFailure::Permanent,
838            RemoteCode::Capability,
839            "per-item capabilities cannot be dispatched in a batch",
840        );
841    }
842    // One exchange carries one principal reference.
843    let handle = &call.members[0].cx.principal_handle;
844    if call
845        .members
846        .iter()
847        .any(|m| &m.cx.principal_handle != handle || m.cx.trace_id != call.members[0].cx.trace_id)
848    {
849        return batch_refused(
850            AdapterFailure::Permanent,
851            RemoteCode::MalformedRequest,
852            "batch members differ in principal or decision",
853        );
854    }
855    let Ok(principal) = inner.config.principal.for_attempt(handle) else {
856        for m in &call.members {
857            let r = unsent_failure(RemoteCode::MalformedRequest, "no principal reference");
858            note_unexchanged(&inner, &m.attempt_id, None, &r);
859        }
860        return batch_refused(
861            AdapterFailure::Permanent,
862            RemoteCode::MalformedRequest,
863            "no principal reference within its bounds; not sent",
864        );
865    };
866    let trace_id = call.members[0].cx.trace_id.clone();
867    let mut answers: Vec<Option<AttemptReport>> = Vec::with_capacity(call.members.len());
868    let mut waiting = vec![];
869    let mut members = vec![];
870    for (i, m) in call.members.iter().enumerate() {
871        // Equal remaining budgets expire together, regardless of preparation order.
872        let budget = Budget::from_start(dispatch_start, m.cx.remaining_ms);
873        if call.cancel.is_raised() || budget.expired() {
874            answers.push(Some(cancelled_report(
875                cancel_outcome(false, inner.remote.terms.cancellation, None),
876                "removed before sending",
877            )));
878            continue;
879        }
880        match prepare(
881            &inner,
882            &m.attempt_id,
883            m.projection.clone(),
884            &principal,
885            budget,
886            &m.cancel,
887        ) {
888            Ok((member, rx, _)) => {
889                answers.push(None);
890                waiting.push((i, rx, budget));
891                members.push(member);
892            }
893            Err(report) => answers.push(Some(report)),
894        }
895    }
896    if !members.is_empty() {
897        let ex = open_batch(&inner, trace_id, principal.clone(), call.batch_id, members);
898        for (i, rx, budget) in waiting {
899            let m = &call.members[i];
900            answers[i] =
901                Some(wait_member(&inner, &ex, &m.attempt_id, rx, budget, m.cancel.clone()).await);
902        }
903    }
904    let reports: Vec<AttemptReport> = answers
905        .into_iter()
906        .map(|r| r.expect("every member answered"))
907        .collect();
908    for (m, r) in call.members.iter().zip(&reports) {
909        note_unexchanged(&inner, &m.attempt_id, principal.as_ref(), r);
910    }
911    BatchAttemptReport {
912        charge: crate::cost::sum_charges(&reports.iter().map(|r| r.charge).collect::<Vec<_>>()),
913        cancel: if reports.iter().any(|r| r.cancel == CancelAck::Unconfirmed) {
914            CancelAck::Unconfirmed
915        } else if reports.iter().any(|r| r.cancel == CancelAck::Stopped) {
916            CancelAck::Stopped
917        } else {
918            CancelAck::NotRequested
919        },
920        // The seam carries one remote state: possibly continuing when any
921        // member's work may be, so liability is never understated.
922        remote: if reports
923            .iter()
924            .any(|r| r.remote == RemoteEnd::PossiblyContinuing)
925        {
926            RemoteEnd::PossiblyContinuing
927        } else {
928            RemoteEnd::Finished
929        },
930        result: Ok(call
931            .members
932            .iter()
933            .zip(reports)
934            .map(|(m, r)| BatchMemberReport {
935                attempt_id: m.attempt_id.clone(),
936                result: r.result,
937            })
938            .collect()),
939    }
940}
941
942/// Open one exchange holding exactly these members, in this order, sent at
943/// once: no window, no joiner.
944fn open_batch(
945    inner: &Arc<Inner>,
946    trace_id: String,
947    principal: Option<WirePrincipal>,
948    batch_id: String,
949    members: Vec<Member>,
950) -> Arc<Exchange> {
951    let (flush_tx, flush_rx) = oneshot::channel();
952    let first_budget = members
953        .iter()
954        .map(|m| m.budget)
955        .reduce(Budget::min)
956        .expect("non-empty");
957    let ex = Arc::new(Exchange {
958        state: Mutex::new(ExState {
959            phase: Phase::Collecting,
960            members,
961            flush: Some(flush_tx),
962            finished: None,
963        }),
964        sent: SentTracker::new(),
965        trace_id,
966        key: None,
967        principal,
968        batch_id: Some(batch_id),
969    });
970    tokio::spawn(run_exchange(
971        inner.clone(),
972        ex.clone(),
973        flush_rx,
974        Duration::ZERO,
975        first_budget,
976    ));
977    ex
978}
979
980/// An attempt that never joined an exchange has evidence too: there was
981/// nothing to exchange (spec 020, 3.2).
982fn note_unexchanged(
983    inner: &Inner,
984    attempt_id: &str,
985    principal: Option<&WirePrincipal>,
986    report: &AttemptReport,
987) {
988    if !inner.config.run_evidence {
989        return;
990    }
991    let mut store = lock(&inner.evidence);
992    if store.map.contains_key(attempt_id) {
993        return;
994    }
995    let mut e = evidence(
996        inner,
997        attempt_id,
998        principal,
999        EvidenceUnavailable::NoExchange,
1000    );
1001    e.code = match &report.result {
1002        Ok(_) => None,
1003        Err((_, detail)) => crate::taxonomy::code_of_detail(detail),
1004    };
1005    e.charge_provenance = ChargeProvenance::NothingSent;
1006    store.put(e);
1007}
1008
1009/// A member stopped before any of its request bytes left: its report, and
1010/// its evidence, prepared as `pending` when it joined, now `no_exchange`
1011/// (spec 020, 3.2). An exchange record that later names it, as an aborted
1012/// exchange's can, completes the entry as for any member.
1013fn stopped_unsent(inner: &Inner, attempt_id: &str, support: CancelSupport) -> AttemptReport {
1014    let report = cancelled_report(
1015        cancel_outcome(false, support, None),
1016        "stopped before sending",
1017    );
1018    if inner.config.run_evidence
1019        && let Some(e) = lock(&inner.evidence).map.get_mut(attempt_id)
1020        && e.exchange
1021            == (ExchangeReference::Unavailable {
1022                reason: EvidenceUnavailable::Pending,
1023            })
1024    {
1025        e.exchange = ExchangeReference::Unavailable {
1026            reason: EvidenceUnavailable::NoExchange,
1027        };
1028        e.code = Some(RemoteCode::Cancelled);
1029        e.charge_provenance = ChargeProvenance::NothingSent;
1030    }
1031    report
1032}
1033
1034fn unsent_failure(code: RemoteCode, detail: &str) -> AttemptReport {
1035    RemoteFailure::new(
1036        code,
1037        detail,
1038        Some(Charge::Observed { units: 0 }),
1039        true,
1040        false,
1041    )
1042    .report()
1043}
1044
1045async fn attempt(
1046    inner: Arc<Inner>,
1047    attempt_id: String,
1048    projection: Vec<u8>,
1049    trace_id: String,
1050    principal: Option<WirePrincipal>,
1051    budget: Budget,
1052    signal: CancelSignal,
1053) -> AttemptReport {
1054    let (member, rx, state) =
1055        match prepare(&inner, &attempt_id, projection, &principal, budget, &signal) {
1056            Ok(p) => p,
1057            Err(report) => return report,
1058        };
1059    // Members share an exchange only with the same state and the same
1060    // principal reference (spec 020, 3.6).
1061    let key = (inner.max_items > 1)
1062        .then_some(state)
1063        .flatten()
1064        .map(|digest| (trace_id.clone(), digest));
1065    let ex = join(&inner, key, trace_id, principal, member);
1066    wait_member(&inner, &ex, &attempt_id, rx, budget, signal).await
1067}
1068
1069/// Check one attempt before it may join an exchange: not cancelled, its id
1070/// never submitted before (3.3.1), within the declared input limit, JSON,
1071/// and a declared operation. Returns the member, its answer channel and the
1072/// digest of its shared state (with the principal), or the report of an
1073/// attempt that sent nothing.
1074fn prepare(
1075    inner: &Arc<Inner>,
1076    attempt_id: &str,
1077    projection: Vec<u8>,
1078    principal: &Option<WirePrincipal>,
1079    budget: Budget,
1080    signal: &CancelSignal,
1081) -> Result<(Member, oneshot::Receiver<AttemptReport>, Option<String>), AttemptReport> {
1082    if signal.is_raised() {
1083        return Err(cancelled_report(
1084            cancel_outcome(false, inner.remote.terms.cancellation, None),
1085            "cancelled before sending",
1086        ));
1087    }
1088    // At most one submission per attempt id (3.3.1).
1089    if !lock(&inner.recent).insert(attempt_id) {
1090        return Err(unsent_failure(
1091            RemoteCode::MalformedRequest,
1092            "attempt id already submitted; not resubmitted",
1093        ));
1094    }
1095    if projection.len() as u64 > inner.descriptor.input_limit.max_bytes {
1096        return Err(unsent_failure(
1097            RemoteCode::Capability,
1098            "input exceeds the declared limit",
1099        ));
1100    }
1101    let (Ok(text), Ok(head)) = (
1102        String::from_utf8(projection.clone()),
1103        rustev_contract::bounded::parse_bounded::<ProjectionHead>(&projection, &REMOTE_V1),
1104    ) else {
1105        return Err(unsent_failure(
1106            RemoteCode::MalformedRequest,
1107            "the projection is not JSON",
1108        ));
1109    };
1110    let Some(capability) = inner
1111        .descriptor
1112        .operations
1113        .iter()
1114        .find(|o| o.operation == head.operation)
1115    else {
1116        return Err(unsent_failure(
1117            RemoteCode::Capability,
1118            "operation not declared",
1119        ));
1120    };
1121    let state = canonical_value_bytes(&serde_json::json!({
1122        "instance": head.instance,
1123        "values": head.values,
1124        "principal": principal.as_ref().map(|p| p.evidence().digest),
1125    }))
1126    .ok()
1127    .map(|b| digest_bytes(&b))
1128    .filter(|_| capability.isolation != Some(Isolation::PerItem));
1129    let (tx, rx) = oneshot::channel();
1130    let member = Member {
1131        attempt_id: attempt_id.to_string(),
1132        projection: text,
1133        kind: capability.output.clone(),
1134        budget,
1135        status: Status::Waiting,
1136        tx: Some(tx),
1137        settled: false,
1138        confirmed: None,
1139        awaiting: false,
1140        late_share: None,
1141    };
1142    if inner.config.run_evidence {
1143        let e = evidence(
1144            inner,
1145            attempt_id,
1146            principal.as_ref(),
1147            EvidenceUnavailable::Pending,
1148        );
1149        lock(&inner.evidence).put(e);
1150    }
1151    Ok((member, rx, state))
1152}
1153
1154/// Wait for a member's answer within its one budget (3.4.1): when it runs
1155/// out the runtime raises the signal at the same moment, and the attempt
1156/// answers as for cancellation (3.4.2) without waiting past it.
1157async fn wait_member(
1158    inner: &Arc<Inner>,
1159    ex: &Arc<Exchange>,
1160    attempt_id: &str,
1161    mut rx: oneshot::Receiver<AttemptReport>,
1162    budget: Budget,
1163    signal: CancelSignal,
1164) -> AttemptReport {
1165    tokio::select! {
1166        biased;
1167        _ = signal.raised() => {}
1168        r = &mut rx => return r.unwrap_or_else(|_| lost(ex.sent.may_have_sent())),
1169        _ = budget.expiry() => {}
1170    }
1171    cancel_member(inner, ex, attempt_id, rx, budget).await
1172}
1173
1174/// Whether a member leaving its exchange must assume its request bytes left
1175/// (or will leave). An aborted tracker means none ever will, whoever else
1176/// waits; with others waiting the request goes out with this item in it;
1177/// alone, the member wins the abort only if no write began (3.4.3).
1178fn bytes_may_leave(sent: &SentTracker, others_waiting: bool) -> bool {
1179    if sent.is_aborted() {
1180        false
1181    } else if others_waiting {
1182        true
1183    } else {
1184        !sent.try_abort()
1185    }
1186}
1187
1188/// The exchange ended without an answer for this member (its task failed).
1189/// If request bytes may have left, the remote work may go on and its charge
1190/// is unknown; otherwise nothing was sent and nothing charged.
1191fn lost(sent: bool) -> AttemptReport {
1192    let reported = (!sent).then_some(Charge::Observed { units: 0 });
1193    RemoteFailure::new(
1194        RemoteCode::RemoteError,
1195        "the exchange ended without an answer",
1196        reported,
1197        true,
1198        sent,
1199    )
1200    .report()
1201}
1202
1203/// Add the member to an open batch with the same key, or open an exchange.
1204fn join(
1205    inner: &Arc<Inner>,
1206    key: Option<(String, String)>,
1207    trace_id: String,
1208    principal: Option<WirePrincipal>,
1209    member: Member,
1210) -> Arc<Exchange> {
1211    let mut open = lock(&inner.open);
1212    if let Some(k) = &key
1213        && let Some(ex) = open.get(k).cloned()
1214    {
1215        let mut st = lock(&ex.state);
1216        if st.phase == Phase::Collecting && st.members.len() < inner.max_items {
1217            st.members.push(member);
1218            if st.members.len() >= inner.max_items
1219                && let Some(f) = st.flush.take()
1220            {
1221                let _ = f.send(());
1222            }
1223            drop(st);
1224            return ex;
1225        }
1226    }
1227    let (flush_tx, flush_rx) = oneshot::channel();
1228    let window = match (key.is_some(), inner.config.batching) {
1229        (true, Some(b)) => Duration::from_millis(b.window_ms),
1230        _ => Duration::ZERO,
1231    };
1232    let first_budget = member.budget;
1233    let ex = Arc::new(Exchange {
1234        state: Mutex::new(ExState {
1235            phase: Phase::Collecting,
1236            members: vec![member],
1237            flush: Some(flush_tx),
1238            finished: None,
1239        }),
1240        sent: SentTracker::new(),
1241        trace_id,
1242        key: key.clone(),
1243        principal,
1244        batch_id: None,
1245    });
1246    if let Some(k) = key {
1247        open.insert(k, ex.clone());
1248    }
1249    drop(open);
1250    tokio::spawn(run_exchange(
1251        inner.clone(),
1252        ex.clone(),
1253        flush_rx,
1254        window,
1255        first_budget,
1256    ));
1257    ex
1258}
1259
1260async fn cancel_member(
1261    inner: &Arc<Inner>,
1262    ex: &Arc<Exchange>,
1263    attempt_id: &str,
1264    mut rx: oneshot::Receiver<AttemptReport>,
1265    budget: Budget,
1266) -> AttemptReport {
1267    let support = inner.remote.terms.cancellation;
1268    let sent;
1269    let awaiting;
1270    {
1271        let mut st = lock(&ex.state);
1272        let phase = st.phase;
1273        let others_waiting = st
1274            .members
1275            .iter()
1276            .any(|m| m.attempt_id != attempt_id && m.status == Status::Waiting);
1277        let Some(m) = st.members.iter_mut().find(|m| m.attempt_id == attempt_id) else {
1278            return lost(ex.sent.may_have_sent());
1279        };
1280        if m.status == Status::Delivered {
1281            // The result was ready first; it wins (spec 003, 3.3.4).
1282            drop(st);
1283            return rx
1284                .try_recv()
1285                .unwrap_or_else(|_| lost(ex.sent.may_have_sent()));
1286        }
1287        match phase {
1288            Phase::Collecting => {
1289                m.status = Status::Withdrawn;
1290                m.settled = true;
1291                m.tx = None;
1292                drop(st);
1293                // Withdrawn before the exchange closed: it is never sent and
1294                // never in the exchange's record.
1295                return stopped_unsent(inner, attempt_id, support);
1296            }
1297            Phase::Sending => {
1298                m.status = Status::Abandoned;
1299                m.tx = None;
1300                // Only a lone member can stop the whole exchange from sending.
1301                sent = bytes_may_leave(&ex.sent, others_waiting);
1302                if !sent {
1303                    m.settled = true;
1304                }
1305                // Waiting for a confirmation is only for a member alone in
1306                // its exchange: with siblings, the exchange's charge is
1307                // attributed to them instead.
1308                awaiting = sent
1309                    && !others_waiting
1310                    && inner.config.await_cancel_confirmation
1311                    && support == CancelSupport::Confirmed;
1312                m.awaiting = awaiting;
1313            }
1314        }
1315    }
1316    if !sent {
1317        return stopped_unsent(inner, attempt_id, support);
1318    }
1319    if support == CancelSupport::None {
1320        return cancelled_report(cancel_outcome(true, support, None), "stopped waiting");
1321    }
1322    let ids = vec![attempt_id.to_string()];
1323    if awaiting {
1324        let confirmed = send_cancel(inner, ids, inner.config.phases.cancel_budget(budget))
1325            .await
1326            .and_then(|answers| stopped_charge(&answers, attempt_id))
1327            .map(|c| convert_charge(c, inner.config.unit_rate));
1328        let outcome = cancel_outcome(true, support, confirmed);
1329        let late = {
1330            let mut st = lock(&ex.state);
1331            let Some(m) = st.members.iter_mut().find(|m| m.attempt_id == attempt_id) else {
1332                return lost(ex.sent.may_have_sent());
1333            };
1334            m.awaiting = false;
1335            let parked = m.late_share.take();
1336            if m.settled {
1337                None
1338            } else if outcome.ack == CancelAck::Stopped {
1339                // Supplied now; the late response offers nothing more.
1340                m.settled = true;
1341                None
1342            } else {
1343                m.settled = parked.is_some();
1344                parked
1345            }
1346        };
1347        if let Some(units) = late {
1348            inner.exchanges.offer(ReconciliationOffer {
1349                attempt_id: attempt_id.to_string(),
1350                observed_units: units,
1351                binding: inner.binding.clone(),
1352            });
1353        }
1354        return cancelled_report(outcome, "cancel requested");
1355    }
1356    // Answer now; a confirmation that arrives later is offered for
1357    // reconciliation, never supplied.
1358    let (inner2, ex2, id) = (inner.clone(), ex.clone(), attempt_id.to_string());
1359    let cancel_budget = inner
1360        .config
1361        .phases
1362        .cancel_budget(Budget::from_ms(inner.config.cancel_budget_ms));
1363    tokio::spawn(async move {
1364        let confirmed = send_cancel(&inner2, ids, cancel_budget)
1365            .await
1366            .and_then(|answers| stopped_charge(&answers, &id))
1367            .map(|c| convert_charge(c, inner2.config.unit_rate));
1368        let Some(c @ Charge::Observed { units }) = confirmed else {
1369            return;
1370        };
1371        let offer = {
1372            let mut st = lock(&ex2.state);
1373            let no_response = st.finished == Some(false);
1374            match st.members.iter_mut().find(|m| m.attempt_id == id) {
1375                Some(m) => {
1376                    m.confirmed = Some(c);
1377                    let now = no_response && !m.settled;
1378                    m.settled |= now;
1379                    now
1380                }
1381                None => false,
1382            }
1383        };
1384        if offer {
1385            inner2.exchanges.offer(ReconciliationOffer {
1386                attempt_id: id,
1387                observed_units: units,
1388                binding: inner2.binding.clone(),
1389            });
1390        }
1391    });
1392    cancelled_report(cancel_outcome(true, support, None), "cancel requested")
1393}
1394
1395fn stopped_charge(answers: &CancelResponse, attempt_id: &str) -> Option<Charge> {
1396    let mut found = answers
1397        .answers
1398        .iter()
1399        .filter(|a| a.attempt_id == attempt_id);
1400    let one = found.next()?;
1401    if found.next().is_some() {
1402        return None;
1403    }
1404    match one.answer {
1405        RemoteCancelAnswer::Stopped { charge } => Some(charge),
1406        _ => None,
1407    }
1408}
1409
1410async fn send_cancel(
1411    inner: &Arc<Inner>,
1412    attempt_ids: Vec<String>,
1413    budget: Budget,
1414) -> Option<CancelResponse> {
1415    let body = CancelRequest {
1416        schema: schema::REMOTE_CANCEL.into(),
1417        version: inner.version,
1418        backend_id: inner.remote.descriptor.backend_id.clone(),
1419        attempt_ids,
1420    }
1421    .canonical()
1422    .ok()?;
1423    let max = (inner.remote.terms.max_response_bytes as usize).min(REMOTE_V1.max_bytes);
1424    let reply = inner
1425        .transport
1426        .post(CANCEL_PATH, body, max, &budget, &SentTracker::new())
1427        .await
1428        .ok()?;
1429    if classify_status(reply.status).is_some() {
1430        return None;
1431    }
1432    CancelResponse::parse(&reply.body).ok()
1433}
1434
1435/// What the exchange determined for one member.
1436struct Determined {
1437    outcome: Result<RawOutput, (RemoteCode, String)>,
1438    /// A charge reported for the exchange, attributed; `None` when nothing
1439    /// was reported.
1440    share: Option<Charge>,
1441    /// Remote work may go on regardless of the charge (`in_progress`).
1442    in_progress: bool,
1443}
1444
1445async fn run_exchange(
1446    inner: Arc<Inner>,
1447    ex: Arc<Exchange>,
1448    flush: oneshot::Receiver<()>,
1449    window: Duration,
1450    first_budget: Budget,
1451) {
1452    if !window.is_zero() {
1453        let until = first_budget.phase_deadline(Some(window));
1454        tokio::select! {
1455            _ = tokio::time::sleep_until(until) => {}
1456            _ = flush => {}
1457        }
1458    }
1459    // Close the batch and snapshot the members still waiting.
1460    let (items, kinds, budget) = {
1461        let mut open = lock(&inner.open);
1462        if let Some(k) = &ex.key
1463            && open.get(k).is_some_and(|e| Arc::ptr_eq(e, &ex))
1464        {
1465            open.remove(k);
1466        }
1467        let mut st = lock(&ex.state);
1468        st.phase = Phase::Sending;
1469        st.members.retain(|m| m.status != Status::Withdrawn);
1470        // A runtime-planned batch keeps the runtime's member order; the
1471        // adapter never reorders it (spec 020, 3.3).
1472        if ex.batch_id.is_none() {
1473            st.members.sort_by(|a, b| a.attempt_id.cmp(&b.attempt_id));
1474        }
1475        if st.members.is_empty() {
1476            return;
1477        }
1478        let budget = st
1479            .members
1480            .iter()
1481            .map(|m| m.budget)
1482            .reduce(Budget::min)
1483            .expect("non-empty");
1484        let items: Vec<InferItem> = st
1485            .members
1486            .iter()
1487            .map(|m| InferItem {
1488                attempt_id: m.attempt_id.clone(),
1489                projection: m.projection.clone(),
1490            })
1491            .collect();
1492        let kinds: Vec<OutputKind> = st.members.iter().map(|m| m.kind.clone()).collect();
1493        (items, kinds, budget)
1494    };
1495    let terms = &inner.remote.terms;
1496    let told = budget.told_ms(inner.config.transit_margin_ms);
1497    let request = InferRequest {
1498        schema: schema::REMOTE_INFER.into(),
1499        version: inner.version,
1500        backend_id: inner.remote.descriptor.backend_id.clone(),
1501        descriptor: inner.remote.descriptor_id.clone(),
1502        artifact: inner.remote.descriptor.artifact.clone(),
1503        budget_ms: told,
1504        trace_id: ex.trace_id.clone(),
1505        privacy: inner.config.privacy.clone(),
1506        items,
1507        principal: ex.principal.clone(),
1508    };
1509    let body = request.canonical().unwrap_or_default();
1510    let mut record = blank_record(inner.protocol, inner.binding.clone(), vec![]);
1511    record.principal = request.principal.as_ref().map(WirePrincipal::evidence);
1512    record.privacy_requested = inner.config.privacy.clone();
1513    record.request_digest = Some(digest_bytes(&body));
1514    if let (Some(b), true) = (inner.config.batching, request.items.len() > 1) {
1515        record.attribution = Attribution::SharedState {
1516            coalescing_window_ms: b.window_ms,
1517        };
1518    }
1519    if ex.batch_id.is_some() {
1520        record.attribution = Attribution::RuntimePlanned;
1521    }
1522    let n = request.items.len();
1523    let all = |code: RemoteCode, detail: &str, share: Option<Charge>| -> Vec<Determined> {
1524        (0..n)
1525            .map(|_| Determined {
1526                outcome: Err((code, detail.to_string())),
1527                share,
1528                in_progress: false,
1529            })
1530            .collect()
1531    };
1532    let mut reply_body: Option<Vec<u8>> = None;
1533    // Members whose own budget still runs are answered promptly when the
1534    // exchange (bounded by the earliest member budget) cannot finish; the
1535    // members whose budget ran out answer their own signals.
1536    let mut live_only = false;
1537    let determined: Vec<Determined> = if told == 0 || body.is_empty() {
1538        // No time to give the remote side, or nothing to send: send nothing.
1539        ex.sent.try_abort();
1540        live_only = true;
1541        all(
1542            RemoteCode::TransportUnsent,
1543            "no budget left to give the remote side",
1544            None,
1545        )
1546    } else if body.len() as u64 > terms.max_request_bytes {
1547        ex.sent.try_abort();
1548        all(
1549            RemoteCode::Capability,
1550            "request exceeds the negotiated size",
1551            None,
1552        )
1553    } else {
1554        let max = (terms.max_response_bytes as usize).min(REMOTE_V1.max_bytes);
1555        let (r, connection) = inner
1556            .transport
1557            .post_observed(
1558                INFER_PATH,
1559                body.clone(),
1560                max,
1561                &budget,
1562                &ex.sent,
1563                ex.principal.as_ref().map(|p| p.evidence().digest),
1564            )
1565            .await;
1566        record.connection = connection;
1567        match r {
1568            Err(e) => {
1569                live_only = budget_ran_out(&e, &budget);
1570                if let TransportError::PhaseExpired(p) = &e {
1571                    record.phase_expiry = Some(*p);
1572                }
1573                let (code, detail) = transport_code(&e);
1574                all(code, &detail, None)
1575            }
1576            Ok(HttpReply {
1577                status,
1578                retry_after,
1579                body,
1580            }) => {
1581                record.status = Some(status);
1582                record.retry_after = retry_after.clone();
1583                record.response_digest = Some(digest_bytes(&body));
1584                reply_body = Some(body.clone());
1585                match classify_status(status) {
1586                    Some(code) => {
1587                        let mut d = format!("HTTP {status}");
1588                        if let Some(r) = &retry_after {
1589                            d.push_str(&format!("; retry-after {r} (recorded, not waited)"));
1590                        }
1591                        all(code, &d, None)
1592                    }
1593                    None => read_response(&inner, &request, &kinds, &body, &mut record),
1594                }
1595            }
1596        }
1597    };
1598    record.bytes_sent = ex.sent.may_have_sent();
1599    // Retained bytes never hold the principal reference: its digest stands
1600    // in for it (spec 020, 3.6).
1601    let retained_request = match &request.principal {
1602        None => body,
1603        Some(p) => {
1604            let mut r = request.clone();
1605            r.principal = Some(WirePrincipal {
1606                reference: p.evidence().digest,
1607                ..p.clone()
1608            });
1609            r.canonical().unwrap_or_default()
1610        }
1611    };
1612    finish(
1613        &inner,
1614        &ex,
1615        determined,
1616        live_only,
1617        record,
1618        retained_request,
1619        reply_body,
1620    );
1621}
1622
1623/// Validate a 2xx body into per-member results (3.5).
1624fn read_response(
1625    inner: &Inner,
1626    request: &InferRequest,
1627    kinds: &[OutputKind],
1628    body: &[u8],
1629    record: &mut rustev_contract::remote::ExchangeRecord,
1630) -> Vec<Determined> {
1631    let n = request.items.len();
1632    let all = |code: RemoteCode, detail: String, share: Option<Charge>| -> Vec<Determined> {
1633        (0..n)
1634            .map(|_| Determined {
1635                outcome: Err((code, detail.clone())),
1636                share,
1637                in_progress: false,
1638            })
1639            .collect()
1640    };
1641    let resp = match InferResponse::parse(body) {
1642        Ok(r) if r.version == inner.version => r,
1643        // A negotiated endpoint that drifts to another version is an
1644        // identity mismatch, never an upgrade or downgrade (spec 020, 3.7).
1645        Ok(r) => {
1646            return all(
1647                RemoteCode::IdentityMismatch,
1648                format!(
1649                    "protocol version {} where {} was negotiated",
1650                    r.version, inner.version
1651                ),
1652                None,
1653            );
1654        }
1655        Err(e) => return all(RemoteCode::MalformedResponse, e.to_string(), None),
1656    };
1657    record.route = resp.route.clone();
1658    record.generation_id = resp.generation_id.clone();
1659    record.provider_extras = resp.extras.clone();
1660    let terms = &inner.remote.terms;
1661    let rate = inner.config.unit_rate;
1662    let total = convert_charge(resp.charge, rate);
1663    record.reported_cost = match resp.charge {
1664        Charge::Observed { units } | Charge::Estimated { units } => {
1665            Some(rustev_contract::remote::ReportedCost {
1666                amount: units.to_string(),
1667                currency: "remote-units".into(),
1668            })
1669        }
1670        Charge::Unknown => None,
1671    };
1672    record.exchange_charge = total;
1673    let reported = (resp.charge != Charge::Unknown).then_some(total);
1674    let served = match served_identity(&terms.served, resp.served.identity.as_deref()) {
1675        Ok(s) => s,
1676        Err(m) => {
1677            record.served = ServedIdentity {
1678                identity: Some(m.served.clone()),
1679                disclosure: Disclosure::Reported,
1680            };
1681            return all(
1682                RemoteCode::IdentityMismatch,
1683                format!("served `{}` where `{}` is pinned", m.served, m.pinned),
1684                reported,
1685            );
1686        }
1687    };
1688    // A name offered under `unknown` terms is kept as a provider extra.
1689    if served.disclosure == Disclosure::Unknown
1690        && let Some(name) = &resp.served.identity
1691    {
1692        record
1693            .provider_extras
1694            .insert("served_identity_claim".into(), name.clone());
1695    }
1696    record.served = served;
1697    if resp.artifact != inner.remote.descriptor.artifact {
1698        return all(
1699            RemoteCode::IdentityMismatch,
1700            "the remote artifact differs from the negotiated one".into(),
1701            reported,
1702        );
1703    }
1704    // Echo checks (3.5.2): each requested id answered exactly once, and no
1705    // answer for an id that was not asked.
1706    let mut counts: HashMap<&str, usize> = HashMap::new();
1707    for a in &resp.items {
1708        *counts.entry(a.attempt_id.as_str()).or_default() += 1;
1709    }
1710    let asked: HashSet<&str> = request
1711        .items
1712        .iter()
1713        .map(|i| i.attempt_id.as_str())
1714        .collect();
1715    if counts.keys().any(|k| !asked.contains(k)) {
1716        return all(
1717            RemoteCode::MalformedResponse,
1718            "an answer names an attempt that was not asked".into(),
1719            reported,
1720        );
1721    }
1722    request
1723        .items
1724        .iter()
1725        .zip(kinds)
1726        .map(|(item, kind)| {
1727            let mut d = Determined {
1728                outcome: Err((RemoteCode::MalformedResponse, String::new())),
1729                share: reported,
1730                in_progress: false,
1731            };
1732            match counts.get(item.attempt_id.as_str()).copied().unwrap_or(0) {
1733                0 => d.outcome = Err((RemoteCode::MalformedResponse, "no answer".into())),
1734                1 => {}
1735                _ => {
1736                    d.outcome = Err((RemoteCode::MalformedResponse, "answered twice".into()));
1737                    return d;
1738                }
1739            }
1740            let Some(a) = resp.items.iter().find(|a| a.attempt_id == item.attempt_id) else {
1741                return d;
1742            };
1743            let declared = Some(kind.clone());
1744            d.outcome = match &a.result {
1745                ItemResult::Output(o) if output_kind_of(o) == declared => Ok(o.clone()),
1746                ItemResult::Output(o) => Err((
1747                    RemoteCode::MalformedResponse,
1748                    format!(
1749                        "a {} answer where {:?} is declared",
1750                        output_kind_of(o)
1751                            .map(|kind| format!("{kind:?}"))
1752                            .unwrap_or_else(|| "TextRecord".into()),
1753                        declared
1754                    ),
1755                )),
1756                // Only the adapter observes its transport; a remote party
1757                // sending such a code is not trusted to zero a charge.
1758                ItemResult::Failure { code, .. } if is_local_only(*code) => Err((
1759                    RemoteCode::MalformedResponse,
1760                    format!("the adapter-only code `{}` in an answer", code.as_str()),
1761                )),
1762                ItemResult::Failure { code, detail } => Err((*code, detail.clone())),
1763                ItemResult::InProgress => {
1764                    d.in_progress = true;
1765                    Err((RemoteCode::RemoteError, "in_progress".into()))
1766                }
1767            };
1768            d
1769        })
1770        .collect()
1771}
1772
1773/// Deliver results to the members still waiting, attribute the charge,
1774/// offer late charges, and write the exchange record.
1775fn finish(
1776    inner: &Inner,
1777    ex: &Exchange,
1778    det: Vec<Determined>,
1779    live_only: bool,
1780    mut record: rustev_contract::remote::ExchangeRecord,
1781    request: Vec<u8>,
1782    response: Option<Vec<u8>>,
1783) {
1784    let terms = &inner.remote.terms;
1785    let sent = record.bytes_sent;
1786    let mut offers = vec![];
1787    // Reports are sent after the record reaches the sink, so a caller that
1788    // has its result can already find the exchange recorded.
1789    let mut reports = vec![];
1790    {
1791        let mut st = lock(&ex.state);
1792        st.finished = Some(response.is_some());
1793        if response.is_none() {
1794            // No response carries a charge: a confirmed cancel is the only
1795            // final charge known for a member that left.
1796            for m in &mut st.members {
1797                if let (Status::Abandoned, false, Some(Charge::Observed { units })) =
1798                    (m.status, m.settled, m.confirmed)
1799                {
1800                    m.settled = true;
1801                    offers.push(ReconciliationOffer {
1802                        attempt_id: m.attempt_id.clone(),
1803                        observed_units: units,
1804                        binding: inner.binding.clone(),
1805                    });
1806                }
1807            }
1808        }
1809        // With `live_only`, a member whose own budget ran out is left to
1810        // answer its signal; the others are answered now.
1811        let receives =
1812            |m: &Member| m.status == Status::Waiting && !(live_only && m.budget.expired());
1813        let received: Vec<bool> = st.members.iter().map(receives).collect();
1814        let any_received = received.iter().any(|r| *r);
1815        let mut members = vec![];
1816        {
1817            let reported = det.first().and_then(|d| d.share);
1818            // Shares go to the members that receive the response; with
1819            // none, the whole charge is offered to those that left.
1820            let shares = match reported {
1821                Some(total) if any_received => attribute(total, &received),
1822                Some(total) => attribute(total, &vec![true; received.len()]),
1823                None => vec![Charge::Unknown; received.len()],
1824            };
1825            record.late = !any_received && response.is_some();
1826            for ((m, d), share) in st.members.iter_mut().zip(det).zip(shares) {
1827                let waiting = receives(m);
1828                let report = match &d.outcome {
1829                    Ok(o) => AttemptReport {
1830                        result: Ok(o.clone()),
1831                        charge: share,
1832                        cancel: CancelAck::NotRequested,
1833                        remote: RemoteEnd::Finished,
1834                    },
1835                    // A remote cancel this member never asked for.
1836                    Err((RemoteCode::Cancelled, detail)) => RemoteFailure::new(
1837                        RemoteCode::RemoteError,
1838                        format!("cancelled at the remote side without a request: {detail}"),
1839                        d.share.map(|_| share),
1840                        terms.refusals_charged,
1841                        sent,
1842                    )
1843                    .report(),
1844                    Err((code, detail)) => {
1845                        let reported = d.share.map(|_| share);
1846                        let mut f = RemoteFailure::new(
1847                            *code,
1848                            detail.clone(),
1849                            reported,
1850                            terms.refusals_charged,
1851                            sent,
1852                        );
1853                        if d.in_progress {
1854                            f.charge = Charge::Unknown;
1855                            f.remote = RemoteEnd::PossiblyContinuing;
1856                        }
1857                        f.report()
1858                    }
1859                };
1860                members.push(ExchangeMember {
1861                    attempt_id: m.attempt_id.clone(),
1862                    outcome: match &d.outcome {
1863                        Ok(o) => MemberOutcome::Output {
1864                            digest: output_digest(o).unwrap_or_default(),
1865                        },
1866                        Err((code, detail)) => MemberOutcome::Failure {
1867                            code: *code,
1868                            detail: detail.clone(),
1869                        },
1870                    },
1871                    charge: if waiting || record.late {
1872                        report.charge
1873                    } else {
1874                        Charge::Unknown
1875                    },
1876                });
1877                if waiting {
1878                    m.status = Status::Delivered;
1879                    m.settled = true;
1880                    if let Some(tx) = m.tx.take() {
1881                        reports.push((tx, report));
1882                    }
1883                } else if m.status == Status::Abandoned && !m.settled && response.is_some() {
1884                    // A charge learned after the member stopped waiting
1885                    // is offered, never supplied (3.4.4, 3.10.4).
1886                    let offered = if any_received {
1887                        // Fully attributed to the receivers.
1888                        matches!(reported, Some(Charge::Observed { .. })).then_some(0)
1889                    } else {
1890                        match share {
1891                            Charge::Observed { units } => Some(units),
1892                            _ => None,
1893                        }
1894                    };
1895                    if m.awaiting {
1896                        m.late_share = offered;
1897                    } else if let Some(units) = offered {
1898                        m.settled = true;
1899                        offers.push(ReconciliationOffer {
1900                            attempt_id: m.attempt_id.clone(),
1901                            observed_units: units,
1902                            binding: inner.binding.clone(),
1903                        });
1904                    }
1905                }
1906            }
1907        }
1908        record.members = members;
1909    }
1910    for o in offers {
1911        inner.exchanges.offer(o);
1912    }
1913    let retained = RetainedBytes { request, response };
1914    let (members, served, sent_any, batch_id) = (
1915        record.members.clone(),
1916        record.served.clone(),
1917        record.bytes_sent,
1918        ex.batch_id
1919            .clone()
1920            .unwrap_or_else(|| record.request_digest.clone().unwrap_or_default()),
1921    );
1922    let note = inner.exchanges.deliver(record, Some(retained));
1923    if inner.config.run_evidence {
1924        // Evidence is complete before any member has its answer.
1925        let exchange = match (note.digest, note.located) {
1926            (Some(digest), Ok(Some(locator))) => ExchangeReference::Retained {
1927                digest,
1928                schema: rustev_contract::schema::REMOTE_EXCHANGE.into(),
1929                retention: if inner.exchanges.retains_bytes() {
1930                    RetentionClass::BytesRetained
1931                } else {
1932                    RetentionClass::DigestOnly
1933                },
1934                locator,
1935            },
1936            (_, Ok(None)) => ExchangeReference::Unavailable {
1937                reason: EvidenceUnavailable::NoLocator,
1938            },
1939            _ => ExchangeReference::Unavailable {
1940                reason: EvidenceUnavailable::NotDelivered,
1941            },
1942        };
1943        let n = members.len();
1944        let mut store = lock(&inner.evidence);
1945        for (index, m) in members.iter().enumerate() {
1946            if let Some(e) = store.map.get_mut(&m.attempt_id) {
1947                e.exchange = exchange.clone();
1948                e.served = served.clone();
1949                e.code = match &m.outcome {
1950                    MemberOutcome::Failure { code, .. } => Some(*code),
1951                    MemberOutcome::Output { .. } => None,
1952                };
1953                e.charge_provenance = match m.charge {
1954                    _ if !sent_any => ChargeProvenance::NothingSent,
1955                    Charge::Unknown => ChargeProvenance::Unknown,
1956                    _ if n > 1 => ChargeProvenance::Attributed,
1957                    _ => ChargeProvenance::Reported,
1958                };
1959                e.batch = (n > 1).then(|| BatchMembership {
1960                    batch_id: batch_id.clone(),
1961                    index: index as u32,
1962                });
1963            }
1964        }
1965    }
1966    for (tx, report) in reports {
1967        let _ = tx.send(report);
1968    }
1969}
1970
1971/// Re-run this client's mapping offline over retained response bytes
1972/// (3.9.4): the output supplied for `attempt_id`, when the response carried
1973/// one of the `declared` kind, else `None`. This checks the mapping, not
1974/// the model; it makes no remote call.
1975pub fn map_retained(body: &[u8], attempt_id: &str, declared: OutputKind) -> Option<RawOutput> {
1976    let resp = InferResponse::parse(body).ok()?;
1977    protocol_name(resp.version)?;
1978    let mut answers = resp.items.iter().filter(|a| a.attempt_id == attempt_id);
1979    let one = answers.next()?;
1980    if answers.next().is_some() {
1981        return None;
1982    }
1983    match &one.result {
1984        ItemResult::Output(o) if output_kind_of(o).as_ref() == Some(&declared) => Some(o.clone()),
1985        _ => None,
1986    }
1987}
1988
1989#[cfg(test)]
1990mod tests {
1991    use super::*;
1992
1993    #[test]
1994    fn an_aborted_exchange_never_counts_as_sent_even_with_siblings() {
1995        let t = SentTracker::new();
1996        assert!(t.try_abort());
1997        assert!(!bytes_may_leave(&t, true));
1998        assert!(!bytes_may_leave(&t, false));
1999        let idle = SentTracker::new();
2000        assert!(bytes_may_leave(&idle, true), "siblings will send it");
2001        let alone = SentTracker::new();
2002        assert!(!bytes_may_leave(&alone, false), "a lone member aborts");
2003        let writing = SentTracker::new();
2004        assert!(writing.begin_write());
2005        assert!(bytes_may_leave(&writing, false));
2006    }
2007
2008    #[test]
2009    fn a_lost_answer_after_sending_is_possibly_continuing() {
2010        let r = lost(true);
2011        assert_eq!(
2012            (r.charge, r.remote),
2013            (Charge::Unknown, RemoteEnd::PossiblyContinuing)
2014        );
2015        let r = lost(false);
2016        assert_eq!(
2017            (r.charge, r.remote),
2018            (Charge::Observed { units: 0 }, RemoteEnd::Finished)
2019        );
2020    }
2021}