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 { .. } | RawOutput::Ranking { .. } => {
683            None
684        }
685        RawOutput::Screen { .. } => Some(OutputKind::Screen),
686        RawOutput::Label(_) => Some(OutputKind::Label),
687        RawOutput::Distribution(_) => Some(OutputKind::Distribution),
688        RawOutput::Logits(_) => Some(OutputKind::Logits),
689        RawOutput::Scores(_) => Some(OutputKind::Scores),
690    }
691}
692
693impl DecisionBackend for RemoteClient {
694    fn descriptor(&self) -> &BackendDescriptor {
695        &self.inner.descriptor
696    }
697
698    /// `bounded` only when the remote side enforces a per-call maximum it
699    /// declared (3.8.1).
700    fn cost_model(&self) -> CostModel {
701        match self.inner.remote.terms.cost {
702            CostBound::Bounded { .. } => CostModel::Bounded,
703            CostBound::Estimated { .. } => CostModel::Estimated,
704            CostBound::Unknown => CostModel::Unknown,
705        }
706    }
707
708    fn cost_bound(&self, _projection: &[u8]) -> CostBound {
709        convert_bound(self.inner.remote.terms.cost, self.inner.config.unit_rate)
710    }
711
712    fn infer<'a>(&'a self, call: AttemptCall<'a>) -> BoxFuture<'a, AttemptReport> {
713        let inner = self.inner.clone();
714        let attempt_id = call.attempt_id.to_string();
715        let signal = call.cancel.clone();
716        let budget = Budget::start(call.cx.remaining_ms);
717        let trace_id = call.cx.trace_id.clone();
718        let projection = call.projection.to_vec();
719        // Resolved now; the handle itself never leaves this call.
720        let principal = inner
721            .config
722            .principal
723            .for_attempt(&call.cx.principal_handle);
724        Box::pin(async move {
725            let Ok(principal) = principal else {
726                // Required context is absent: refused before any byte.
727                let report = unsent_failure(
728                    RemoteCode::MalformedRequest,
729                    "no principal reference within its bounds; not sent",
730                );
731                note_unexchanged(&inner, &attempt_id, None, &report);
732                return report;
733            };
734            let p = principal.clone();
735            let report = attempt(
736                inner.clone(),
737                attempt_id.clone(),
738                projection,
739                trace_id,
740                principal,
741                budget,
742                signal,
743            )
744            .await;
745            note_unexchanged(&inner, &attempt_id, p.as_ref(), &report);
746            report
747        })
748    }
749
750    fn take_remote_evidence(&self, attempt_id: &str) -> Option<RemoteEvidence> {
751        lock(&self.inner.evidence).take(attempt_id)
752    }
753
754    /// Offered only when the binding enables runtime-planned batches and the
755    /// negotiated terms declare shared-state batching (spec 020, 3.3).
756    /// Any per-item operation disables batching for the entire backend
757    /// (spec 035, 3.15.3, and spec 044, 3.7); the projected operation must
758    /// also be declared.
759    /// Equal compatibility means the same binding and the same shared state
760    /// (`instance` and `values`), which is what one request may carry.
761    fn batch_capability(&self, projection: &[u8]) -> Option<BatchCapability> {
762        self.inner.batch_max?;
763        if self
764            .inner
765            .descriptor
766            .operations
767            .iter()
768            .any(|o| o.isolation == Some(Isolation::PerItem))
769        {
770            return None;
771        }
772        let head =
773            rustev_contract::bounded::parse_bounded::<ProjectionHead>(projection, &REMOTE_V1)
774                .ok()?;
775        self.inner
776            .descriptor
777            .operations
778            .iter()
779            .any(|o| o.operation == head.operation)
780            .then_some(())?;
781        let state = canonical_value_bytes(&serde_json::json!({
782            "binding": self.inner.binding.as_str(),
783            "instance": head.instance,
784            "values": head.values,
785        }))
786        .ok()?;
787        Some(BatchCapability {
788            compatibility: digest_bytes(&state),
789            work_units: 1,
790        })
791    }
792
793    fn infer_batch<'a>(&'a self, call: BatchAttemptCall) -> BoxFuture<'a, BatchAttemptReport> {
794        let inner = self.inner.clone();
795        Box::pin(async move { batch(inner, call).await })
796    }
797}
798
799/// A whole-batch refusal before any byte was sent.
800fn batch_refused(failure: AdapterFailure, code: RemoteCode, detail: &str) -> BatchAttemptReport {
801    BatchAttemptReport {
802        result: Err((failure, crate::taxonomy::failure_detail(code, detail))),
803        charge: Charge::Observed { units: 0 },
804        cancel: CancelAck::NotRequested,
805        remote: RemoteEnd::Finished,
806    }
807}
808
809/// One runtime-planned batch (spec 020, 3.3): every member checked as an
810/// independent attempt would be, the dispatchable ones sent in one exchange
811/// in the runtime's order, each answered on its own (its output, failure,
812/// cancellation and remote state), never filled from a sibling. A member
813/// cancelled before the first request byte is removed and reported unsent.
814async fn batch(inner: Arc<Inner>, call: BatchAttemptCall) -> BatchAttemptReport {
815    let dispatch_start = tokio::time::Instant::now();
816    let Some(max) = inner.batch_max else {
817        return batch_refused(
818            AdapterFailure::Permanent,
819            RemoteCode::Capability,
820            "runtime-planned batches are not enabled",
821        );
822    };
823    if call.members.is_empty() || call.members.len() > max {
824        return batch_refused(
825            AdapterFailure::Permanent,
826            RemoteCode::Capability,
827            "the batch exceeds the negotiated bound",
828        );
829    }
830    // Apply the backend-wide server rule even when callers bypass
831    // batch_capability (spec 035, 3.15.3, and spec 044, 3.7).
832    if call.members.len() > 1
833        && inner
834            .descriptor
835            .operations
836            .iter()
837            .any(|o| o.isolation == Some(Isolation::PerItem))
838    {
839        return batch_refused(
840            AdapterFailure::Permanent,
841            RemoteCode::Capability,
842            "per-item capabilities cannot be dispatched in a batch",
843        );
844    }
845    // One exchange carries one principal reference.
846    let handle = &call.members[0].cx.principal_handle;
847    if call
848        .members
849        .iter()
850        .any(|m| &m.cx.principal_handle != handle || m.cx.trace_id != call.members[0].cx.trace_id)
851    {
852        return batch_refused(
853            AdapterFailure::Permanent,
854            RemoteCode::MalformedRequest,
855            "batch members differ in principal or decision",
856        );
857    }
858    let Ok(principal) = inner.config.principal.for_attempt(handle) else {
859        for m in &call.members {
860            let r = unsent_failure(RemoteCode::MalformedRequest, "no principal reference");
861            note_unexchanged(&inner, &m.attempt_id, None, &r);
862        }
863        return batch_refused(
864            AdapterFailure::Permanent,
865            RemoteCode::MalformedRequest,
866            "no principal reference within its bounds; not sent",
867        );
868    };
869    let trace_id = call.members[0].cx.trace_id.clone();
870    let mut answers: Vec<Option<AttemptReport>> = Vec::with_capacity(call.members.len());
871    let mut waiting = vec![];
872    let mut members = vec![];
873    for (i, m) in call.members.iter().enumerate() {
874        // Equal remaining budgets expire together, regardless of preparation order.
875        let budget = Budget::from_start(dispatch_start, m.cx.remaining_ms);
876        if call.cancel.is_raised() || budget.expired() {
877            answers.push(Some(cancelled_report(
878                cancel_outcome(false, inner.remote.terms.cancellation, None),
879                "removed before sending",
880            )));
881            continue;
882        }
883        match prepare(
884            &inner,
885            &m.attempt_id,
886            m.projection.clone(),
887            &principal,
888            budget,
889            &m.cancel,
890        ) {
891            Ok((member, rx, _)) => {
892                answers.push(None);
893                waiting.push((i, rx, budget));
894                members.push(member);
895            }
896            Err(report) => answers.push(Some(report)),
897        }
898    }
899    if !members.is_empty() {
900        let ex = open_batch(&inner, trace_id, principal.clone(), call.batch_id, members);
901        for (i, rx, budget) in waiting {
902            let m = &call.members[i];
903            answers[i] =
904                Some(wait_member(&inner, &ex, &m.attempt_id, rx, budget, m.cancel.clone()).await);
905        }
906    }
907    let reports: Vec<AttemptReport> = answers
908        .into_iter()
909        .map(|r| r.expect("every member answered"))
910        .collect();
911    for (m, r) in call.members.iter().zip(&reports) {
912        note_unexchanged(&inner, &m.attempt_id, principal.as_ref(), r);
913    }
914    BatchAttemptReport {
915        charge: crate::cost::sum_charges(&reports.iter().map(|r| r.charge).collect::<Vec<_>>()),
916        cancel: if reports.iter().any(|r| r.cancel == CancelAck::Unconfirmed) {
917            CancelAck::Unconfirmed
918        } else if reports.iter().any(|r| r.cancel == CancelAck::Stopped) {
919            CancelAck::Stopped
920        } else {
921            CancelAck::NotRequested
922        },
923        // The seam carries one remote state: possibly continuing when any
924        // member's work may be, so liability is never understated.
925        remote: if reports
926            .iter()
927            .any(|r| r.remote == RemoteEnd::PossiblyContinuing)
928        {
929            RemoteEnd::PossiblyContinuing
930        } else {
931            RemoteEnd::Finished
932        },
933        result: Ok(call
934            .members
935            .iter()
936            .zip(reports)
937            .map(|(m, r)| BatchMemberReport {
938                attempt_id: m.attempt_id.clone(),
939                result: r.result,
940            })
941            .collect()),
942    }
943}
944
945/// Open one exchange holding exactly these members, in this order, sent at
946/// once: no window, no joiner.
947fn open_batch(
948    inner: &Arc<Inner>,
949    trace_id: String,
950    principal: Option<WirePrincipal>,
951    batch_id: String,
952    members: Vec<Member>,
953) -> Arc<Exchange> {
954    let (flush_tx, flush_rx) = oneshot::channel();
955    let first_budget = members
956        .iter()
957        .map(|m| m.budget)
958        .reduce(Budget::min)
959        .expect("non-empty");
960    let ex = Arc::new(Exchange {
961        state: Mutex::new(ExState {
962            phase: Phase::Collecting,
963            members,
964            flush: Some(flush_tx),
965            finished: None,
966        }),
967        sent: SentTracker::new(),
968        trace_id,
969        key: None,
970        principal,
971        batch_id: Some(batch_id),
972    });
973    tokio::spawn(run_exchange(
974        inner.clone(),
975        ex.clone(),
976        flush_rx,
977        Duration::ZERO,
978        first_budget,
979    ));
980    ex
981}
982
983/// An attempt that never joined an exchange has evidence too: there was
984/// nothing to exchange (spec 020, 3.2).
985fn note_unexchanged(
986    inner: &Inner,
987    attempt_id: &str,
988    principal: Option<&WirePrincipal>,
989    report: &AttemptReport,
990) {
991    if !inner.config.run_evidence {
992        return;
993    }
994    let mut store = lock(&inner.evidence);
995    if store.map.contains_key(attempt_id) {
996        return;
997    }
998    let mut e = evidence(
999        inner,
1000        attempt_id,
1001        principal,
1002        EvidenceUnavailable::NoExchange,
1003    );
1004    e.code = match &report.result {
1005        Ok(_) => None,
1006        Err((_, detail)) => crate::taxonomy::code_of_detail(detail),
1007    };
1008    e.charge_provenance = ChargeProvenance::NothingSent;
1009    store.put(e);
1010}
1011
1012/// A member stopped before any of its request bytes left: its report, and
1013/// its evidence, prepared as `pending` when it joined, now `no_exchange`
1014/// (spec 020, 3.2). An exchange record that later names it, as an aborted
1015/// exchange's can, completes the entry as for any member.
1016fn stopped_unsent(inner: &Inner, attempt_id: &str, support: CancelSupport) -> AttemptReport {
1017    let report = cancelled_report(
1018        cancel_outcome(false, support, None),
1019        "stopped before sending",
1020    );
1021    if inner.config.run_evidence
1022        && let Some(e) = lock(&inner.evidence).map.get_mut(attempt_id)
1023        && e.exchange
1024            == (ExchangeReference::Unavailable {
1025                reason: EvidenceUnavailable::Pending,
1026            })
1027    {
1028        e.exchange = ExchangeReference::Unavailable {
1029            reason: EvidenceUnavailable::NoExchange,
1030        };
1031        e.code = Some(RemoteCode::Cancelled);
1032        e.charge_provenance = ChargeProvenance::NothingSent;
1033    }
1034    report
1035}
1036
1037fn unsent_failure(code: RemoteCode, detail: &str) -> AttemptReport {
1038    RemoteFailure::new(
1039        code,
1040        detail,
1041        Some(Charge::Observed { units: 0 }),
1042        true,
1043        false,
1044    )
1045    .report()
1046}
1047
1048async fn attempt(
1049    inner: Arc<Inner>,
1050    attempt_id: String,
1051    projection: Vec<u8>,
1052    trace_id: String,
1053    principal: Option<WirePrincipal>,
1054    budget: Budget,
1055    signal: CancelSignal,
1056) -> AttemptReport {
1057    let (member, rx, state) =
1058        match prepare(&inner, &attempt_id, projection, &principal, budget, &signal) {
1059            Ok(p) => p,
1060            Err(report) => return report,
1061        };
1062    // Members share an exchange only with the same state and the same
1063    // principal reference (spec 020, 3.6).
1064    let key = (inner.max_items > 1)
1065        .then_some(state)
1066        .flatten()
1067        .map(|digest| (trace_id.clone(), digest));
1068    let ex = join(&inner, key, trace_id, principal, member);
1069    wait_member(&inner, &ex, &attempt_id, rx, budget, signal).await
1070}
1071
1072/// Check one attempt before it may join an exchange: not cancelled, its id
1073/// never submitted before (3.3.1), within the declared input limit, JSON,
1074/// and a declared operation. Returns the member, its answer channel and the
1075/// digest of its shared state (with the principal), or the report of an
1076/// attempt that sent nothing.
1077fn prepare(
1078    inner: &Arc<Inner>,
1079    attempt_id: &str,
1080    projection: Vec<u8>,
1081    principal: &Option<WirePrincipal>,
1082    budget: Budget,
1083    signal: &CancelSignal,
1084) -> Result<(Member, oneshot::Receiver<AttemptReport>, Option<String>), AttemptReport> {
1085    if signal.is_raised() {
1086        return Err(cancelled_report(
1087            cancel_outcome(false, inner.remote.terms.cancellation, None),
1088            "cancelled before sending",
1089        ));
1090    }
1091    // At most one submission per attempt id (3.3.1).
1092    if !lock(&inner.recent).insert(attempt_id) {
1093        return Err(unsent_failure(
1094            RemoteCode::MalformedRequest,
1095            "attempt id already submitted; not resubmitted",
1096        ));
1097    }
1098    if projection.len() as u64 > inner.descriptor.input_limit.max_bytes {
1099        return Err(unsent_failure(
1100            RemoteCode::Capability,
1101            "input exceeds the declared limit",
1102        ));
1103    }
1104    let (Ok(text), Ok(head)) = (
1105        String::from_utf8(projection.clone()),
1106        rustev_contract::bounded::parse_bounded::<ProjectionHead>(&projection, &REMOTE_V1),
1107    ) else {
1108        return Err(unsent_failure(
1109            RemoteCode::MalformedRequest,
1110            "the projection is not JSON",
1111        ));
1112    };
1113    let Some(capability) = inner
1114        .descriptor
1115        .operations
1116        .iter()
1117        .find(|o| o.operation == head.operation)
1118    else {
1119        return Err(unsent_failure(
1120            RemoteCode::Capability,
1121            "operation not declared",
1122        ));
1123    };
1124    let state = canonical_value_bytes(&serde_json::json!({
1125        "instance": head.instance,
1126        "values": head.values,
1127        "principal": principal.as_ref().map(|p| p.evidence().digest),
1128    }))
1129    .ok()
1130    .map(|b| digest_bytes(&b))
1131    .filter(|_| capability.isolation != Some(Isolation::PerItem));
1132    let (tx, rx) = oneshot::channel();
1133    let member = Member {
1134        attempt_id: attempt_id.to_string(),
1135        projection: text,
1136        kind: capability.output.clone(),
1137        budget,
1138        status: Status::Waiting,
1139        tx: Some(tx),
1140        settled: false,
1141        confirmed: None,
1142        awaiting: false,
1143        late_share: None,
1144    };
1145    if inner.config.run_evidence {
1146        let e = evidence(
1147            inner,
1148            attempt_id,
1149            principal.as_ref(),
1150            EvidenceUnavailable::Pending,
1151        );
1152        lock(&inner.evidence).put(e);
1153    }
1154    Ok((member, rx, state))
1155}
1156
1157/// Wait for a member's answer within its one budget (3.4.1): when it runs
1158/// out the runtime raises the signal at the same moment, and the attempt
1159/// answers as for cancellation (3.4.2) without waiting past it.
1160async fn wait_member(
1161    inner: &Arc<Inner>,
1162    ex: &Arc<Exchange>,
1163    attempt_id: &str,
1164    mut rx: oneshot::Receiver<AttemptReport>,
1165    budget: Budget,
1166    signal: CancelSignal,
1167) -> AttemptReport {
1168    tokio::select! {
1169        biased;
1170        _ = signal.raised() => {}
1171        r = &mut rx => return r.unwrap_or_else(|_| lost(ex.sent.may_have_sent())),
1172        _ = budget.expiry() => {}
1173    }
1174    cancel_member(inner, ex, attempt_id, rx, budget).await
1175}
1176
1177/// Whether a member leaving its exchange must assume its request bytes left
1178/// (or will leave). An aborted tracker means none ever will, whoever else
1179/// waits; with others waiting the request goes out with this item in it;
1180/// alone, the member wins the abort only if no write began (3.4.3).
1181fn bytes_may_leave(sent: &SentTracker, others_waiting: bool) -> bool {
1182    if sent.is_aborted() {
1183        false
1184    } else if others_waiting {
1185        true
1186    } else {
1187        !sent.try_abort()
1188    }
1189}
1190
1191/// The exchange ended without an answer for this member (its task failed).
1192/// If request bytes may have left, the remote work may go on and its charge
1193/// is unknown; otherwise nothing was sent and nothing charged.
1194fn lost(sent: bool) -> AttemptReport {
1195    let reported = (!sent).then_some(Charge::Observed { units: 0 });
1196    RemoteFailure::new(
1197        RemoteCode::RemoteError,
1198        "the exchange ended without an answer",
1199        reported,
1200        true,
1201        sent,
1202    )
1203    .report()
1204}
1205
1206/// Add the member to an open batch with the same key, or open an exchange.
1207fn join(
1208    inner: &Arc<Inner>,
1209    key: Option<(String, String)>,
1210    trace_id: String,
1211    principal: Option<WirePrincipal>,
1212    member: Member,
1213) -> Arc<Exchange> {
1214    let mut open = lock(&inner.open);
1215    if let Some(k) = &key
1216        && let Some(ex) = open.get(k).cloned()
1217    {
1218        let mut st = lock(&ex.state);
1219        if st.phase == Phase::Collecting && st.members.len() < inner.max_items {
1220            st.members.push(member);
1221            if st.members.len() >= inner.max_items
1222                && let Some(f) = st.flush.take()
1223            {
1224                let _ = f.send(());
1225            }
1226            drop(st);
1227            return ex;
1228        }
1229    }
1230    let (flush_tx, flush_rx) = oneshot::channel();
1231    let window = match (key.is_some(), inner.config.batching) {
1232        (true, Some(b)) => Duration::from_millis(b.window_ms),
1233        _ => Duration::ZERO,
1234    };
1235    let first_budget = member.budget;
1236    let ex = Arc::new(Exchange {
1237        state: Mutex::new(ExState {
1238            phase: Phase::Collecting,
1239            members: vec![member],
1240            flush: Some(flush_tx),
1241            finished: None,
1242        }),
1243        sent: SentTracker::new(),
1244        trace_id,
1245        key: key.clone(),
1246        principal,
1247        batch_id: None,
1248    });
1249    if let Some(k) = key {
1250        open.insert(k, ex.clone());
1251    }
1252    drop(open);
1253    tokio::spawn(run_exchange(
1254        inner.clone(),
1255        ex.clone(),
1256        flush_rx,
1257        window,
1258        first_budget,
1259    ));
1260    ex
1261}
1262
1263async fn cancel_member(
1264    inner: &Arc<Inner>,
1265    ex: &Arc<Exchange>,
1266    attempt_id: &str,
1267    mut rx: oneshot::Receiver<AttemptReport>,
1268    budget: Budget,
1269) -> AttemptReport {
1270    let support = inner.remote.terms.cancellation;
1271    let sent;
1272    let awaiting;
1273    {
1274        let mut st = lock(&ex.state);
1275        let phase = st.phase;
1276        let others_waiting = st
1277            .members
1278            .iter()
1279            .any(|m| m.attempt_id != attempt_id && m.status == Status::Waiting);
1280        let Some(m) = st.members.iter_mut().find(|m| m.attempt_id == attempt_id) else {
1281            return lost(ex.sent.may_have_sent());
1282        };
1283        if m.status == Status::Delivered {
1284            // The result was ready first; it wins (spec 003, 3.3.4).
1285            drop(st);
1286            return rx
1287                .try_recv()
1288                .unwrap_or_else(|_| lost(ex.sent.may_have_sent()));
1289        }
1290        match phase {
1291            Phase::Collecting => {
1292                m.status = Status::Withdrawn;
1293                m.settled = true;
1294                m.tx = None;
1295                drop(st);
1296                // Withdrawn before the exchange closed: it is never sent and
1297                // never in the exchange's record.
1298                return stopped_unsent(inner, attempt_id, support);
1299            }
1300            Phase::Sending => {
1301                m.status = Status::Abandoned;
1302                m.tx = None;
1303                // Only a lone member can stop the whole exchange from sending.
1304                sent = bytes_may_leave(&ex.sent, others_waiting);
1305                if !sent {
1306                    m.settled = true;
1307                }
1308                // Waiting for a confirmation is only for a member alone in
1309                // its exchange: with siblings, the exchange's charge is
1310                // attributed to them instead.
1311                awaiting = sent
1312                    && !others_waiting
1313                    && inner.config.await_cancel_confirmation
1314                    && support == CancelSupport::Confirmed;
1315                m.awaiting = awaiting;
1316            }
1317        }
1318    }
1319    if !sent {
1320        return stopped_unsent(inner, attempt_id, support);
1321    }
1322    if support == CancelSupport::None {
1323        return cancelled_report(cancel_outcome(true, support, None), "stopped waiting");
1324    }
1325    let ids = vec![attempt_id.to_string()];
1326    if awaiting {
1327        let confirmed = send_cancel(inner, ids, inner.config.phases.cancel_budget(budget))
1328            .await
1329            .and_then(|answers| stopped_charge(&answers, attempt_id))
1330            .map(|c| convert_charge(c, inner.config.unit_rate));
1331        let outcome = cancel_outcome(true, support, confirmed);
1332        let late = {
1333            let mut st = lock(&ex.state);
1334            let Some(m) = st.members.iter_mut().find(|m| m.attempt_id == attempt_id) else {
1335                return lost(ex.sent.may_have_sent());
1336            };
1337            m.awaiting = false;
1338            let parked = m.late_share.take();
1339            if m.settled {
1340                None
1341            } else if outcome.ack == CancelAck::Stopped {
1342                // Supplied now; the late response offers nothing more.
1343                m.settled = true;
1344                None
1345            } else {
1346                m.settled = parked.is_some();
1347                parked
1348            }
1349        };
1350        if let Some(units) = late {
1351            inner.exchanges.offer(ReconciliationOffer {
1352                attempt_id: attempt_id.to_string(),
1353                observed_units: units,
1354                binding: inner.binding.clone(),
1355            });
1356        }
1357        return cancelled_report(outcome, "cancel requested");
1358    }
1359    // Answer now; a confirmation that arrives later is offered for
1360    // reconciliation, never supplied.
1361    let (inner2, ex2, id) = (inner.clone(), ex.clone(), attempt_id.to_string());
1362    let cancel_budget = inner
1363        .config
1364        .phases
1365        .cancel_budget(Budget::from_ms(inner.config.cancel_budget_ms));
1366    tokio::spawn(async move {
1367        let confirmed = send_cancel(&inner2, ids, cancel_budget)
1368            .await
1369            .and_then(|answers| stopped_charge(&answers, &id))
1370            .map(|c| convert_charge(c, inner2.config.unit_rate));
1371        let Some(c @ Charge::Observed { units }) = confirmed else {
1372            return;
1373        };
1374        let offer = {
1375            let mut st = lock(&ex2.state);
1376            let no_response = st.finished == Some(false);
1377            match st.members.iter_mut().find(|m| m.attempt_id == id) {
1378                Some(m) => {
1379                    m.confirmed = Some(c);
1380                    let now = no_response && !m.settled;
1381                    m.settled |= now;
1382                    now
1383                }
1384                None => false,
1385            }
1386        };
1387        if offer {
1388            inner2.exchanges.offer(ReconciliationOffer {
1389                attempt_id: id,
1390                observed_units: units,
1391                binding: inner2.binding.clone(),
1392            });
1393        }
1394    });
1395    cancelled_report(cancel_outcome(true, support, None), "cancel requested")
1396}
1397
1398fn stopped_charge(answers: &CancelResponse, attempt_id: &str) -> Option<Charge> {
1399    let mut found = answers
1400        .answers
1401        .iter()
1402        .filter(|a| a.attempt_id == attempt_id);
1403    let one = found.next()?;
1404    if found.next().is_some() {
1405        return None;
1406    }
1407    match one.answer {
1408        RemoteCancelAnswer::Stopped { charge } => Some(charge),
1409        _ => None,
1410    }
1411}
1412
1413async fn send_cancel(
1414    inner: &Arc<Inner>,
1415    attempt_ids: Vec<String>,
1416    budget: Budget,
1417) -> Option<CancelResponse> {
1418    let body = CancelRequest {
1419        schema: schema::REMOTE_CANCEL.into(),
1420        version: inner.version,
1421        backend_id: inner.remote.descriptor.backend_id.clone(),
1422        attempt_ids,
1423    }
1424    .canonical()
1425    .ok()?;
1426    let max = (inner.remote.terms.max_response_bytes as usize).min(REMOTE_V1.max_bytes);
1427    let reply = inner
1428        .transport
1429        .post(CANCEL_PATH, body, max, &budget, &SentTracker::new())
1430        .await
1431        .ok()?;
1432    if classify_status(reply.status).is_some() {
1433        return None;
1434    }
1435    CancelResponse::parse(&reply.body).ok()
1436}
1437
1438/// What the exchange determined for one member.
1439struct Determined {
1440    outcome: Result<RawOutput, (RemoteCode, String)>,
1441    /// A charge reported for the exchange, attributed; `None` when nothing
1442    /// was reported.
1443    share: Option<Charge>,
1444    /// Remote work may go on regardless of the charge (`in_progress`).
1445    in_progress: bool,
1446}
1447
1448async fn run_exchange(
1449    inner: Arc<Inner>,
1450    ex: Arc<Exchange>,
1451    flush: oneshot::Receiver<()>,
1452    window: Duration,
1453    first_budget: Budget,
1454) {
1455    if !window.is_zero() {
1456        let until = first_budget.phase_deadline(Some(window));
1457        tokio::select! {
1458            _ = tokio::time::sleep_until(until) => {}
1459            _ = flush => {}
1460        }
1461    }
1462    // Close the batch and snapshot the members still waiting.
1463    let (items, kinds, budget) = {
1464        let mut open = lock(&inner.open);
1465        if let Some(k) = &ex.key
1466            && open.get(k).is_some_and(|e| Arc::ptr_eq(e, &ex))
1467        {
1468            open.remove(k);
1469        }
1470        let mut st = lock(&ex.state);
1471        st.phase = Phase::Sending;
1472        st.members.retain(|m| m.status != Status::Withdrawn);
1473        // A runtime-planned batch keeps the runtime's member order; the
1474        // adapter never reorders it (spec 020, 3.3).
1475        if ex.batch_id.is_none() {
1476            st.members.sort_by(|a, b| a.attempt_id.cmp(&b.attempt_id));
1477        }
1478        if st.members.is_empty() {
1479            return;
1480        }
1481        let budget = st
1482            .members
1483            .iter()
1484            .map(|m| m.budget)
1485            .reduce(Budget::min)
1486            .expect("non-empty");
1487        let items: Vec<InferItem> = st
1488            .members
1489            .iter()
1490            .map(|m| InferItem {
1491                attempt_id: m.attempt_id.clone(),
1492                projection: m.projection.clone(),
1493            })
1494            .collect();
1495        let kinds: Vec<OutputKind> = st.members.iter().map(|m| m.kind.clone()).collect();
1496        (items, kinds, budget)
1497    };
1498    let terms = &inner.remote.terms;
1499    let told = budget.told_ms(inner.config.transit_margin_ms);
1500    let request = InferRequest {
1501        schema: schema::REMOTE_INFER.into(),
1502        version: inner.version,
1503        backend_id: inner.remote.descriptor.backend_id.clone(),
1504        descriptor: inner.remote.descriptor_id.clone(),
1505        artifact: inner.remote.descriptor.artifact.clone(),
1506        budget_ms: told,
1507        trace_id: ex.trace_id.clone(),
1508        privacy: inner.config.privacy.clone(),
1509        items,
1510        principal: ex.principal.clone(),
1511    };
1512    let body = request.canonical().unwrap_or_default();
1513    let mut record = blank_record(inner.protocol, inner.binding.clone(), vec![]);
1514    record.principal = request.principal.as_ref().map(WirePrincipal::evidence);
1515    record.privacy_requested = inner.config.privacy.clone();
1516    record.request_digest = Some(digest_bytes(&body));
1517    if let (Some(b), true) = (inner.config.batching, request.items.len() > 1) {
1518        record.attribution = Attribution::SharedState {
1519            coalescing_window_ms: b.window_ms,
1520        };
1521    }
1522    if ex.batch_id.is_some() {
1523        record.attribution = Attribution::RuntimePlanned;
1524    }
1525    let n = request.items.len();
1526    let all = |code: RemoteCode, detail: &str, share: Option<Charge>| -> Vec<Determined> {
1527        (0..n)
1528            .map(|_| Determined {
1529                outcome: Err((code, detail.to_string())),
1530                share,
1531                in_progress: false,
1532            })
1533            .collect()
1534    };
1535    let mut reply_body: Option<Vec<u8>> = None;
1536    // Members whose own budget still runs are answered promptly when the
1537    // exchange (bounded by the earliest member budget) cannot finish; the
1538    // members whose budget ran out answer their own signals.
1539    let mut live_only = false;
1540    let determined: Vec<Determined> = if told == 0 || body.is_empty() {
1541        // No time to give the remote side, or nothing to send: send nothing.
1542        ex.sent.try_abort();
1543        live_only = true;
1544        all(
1545            RemoteCode::TransportUnsent,
1546            "no budget left to give the remote side",
1547            None,
1548        )
1549    } else if body.len() as u64 > terms.max_request_bytes {
1550        ex.sent.try_abort();
1551        all(
1552            RemoteCode::Capability,
1553            "request exceeds the negotiated size",
1554            None,
1555        )
1556    } else {
1557        let max = (terms.max_response_bytes as usize).min(REMOTE_V1.max_bytes);
1558        let (r, connection) = inner
1559            .transport
1560            .post_observed(
1561                INFER_PATH,
1562                body.clone(),
1563                max,
1564                &budget,
1565                &ex.sent,
1566                ex.principal.as_ref().map(|p| p.evidence().digest),
1567            )
1568            .await;
1569        record.connection = connection;
1570        match r {
1571            Err(e) => {
1572                live_only = budget_ran_out(&e, &budget);
1573                if let TransportError::PhaseExpired(p) = &e {
1574                    record.phase_expiry = Some(*p);
1575                }
1576                let (code, detail) = transport_code(&e);
1577                all(code, &detail, None)
1578            }
1579            Ok(HttpReply {
1580                status,
1581                retry_after,
1582                body,
1583            }) => {
1584                record.status = Some(status);
1585                record.retry_after = retry_after.clone();
1586                record.response_digest = Some(digest_bytes(&body));
1587                reply_body = Some(body.clone());
1588                match classify_status(status) {
1589                    Some(code) => {
1590                        let mut d = format!("HTTP {status}");
1591                        if let Some(r) = &retry_after {
1592                            d.push_str(&format!("; retry-after {r} (recorded, not waited)"));
1593                        }
1594                        all(code, &d, None)
1595                    }
1596                    None => read_response(&inner, &request, &kinds, &body, &mut record),
1597                }
1598            }
1599        }
1600    };
1601    record.bytes_sent = ex.sent.may_have_sent();
1602    // Retained bytes never hold the principal reference: its digest stands
1603    // in for it (spec 020, 3.6).
1604    let retained_request = match &request.principal {
1605        None => body,
1606        Some(p) => {
1607            let mut r = request.clone();
1608            r.principal = Some(WirePrincipal {
1609                reference: p.evidence().digest,
1610                ..p.clone()
1611            });
1612            r.canonical().unwrap_or_default()
1613        }
1614    };
1615    finish(
1616        &inner,
1617        &ex,
1618        determined,
1619        live_only,
1620        record,
1621        retained_request,
1622        reply_body,
1623    );
1624}
1625
1626/// Validate a 2xx body into per-member results (3.5).
1627fn read_response(
1628    inner: &Inner,
1629    request: &InferRequest,
1630    kinds: &[OutputKind],
1631    body: &[u8],
1632    record: &mut rustev_contract::remote::ExchangeRecord,
1633) -> Vec<Determined> {
1634    let n = request.items.len();
1635    let all = |code: RemoteCode, detail: String, share: Option<Charge>| -> Vec<Determined> {
1636        (0..n)
1637            .map(|_| Determined {
1638                outcome: Err((code, detail.clone())),
1639                share,
1640                in_progress: false,
1641            })
1642            .collect()
1643    };
1644    let resp = match InferResponse::parse(body) {
1645        Ok(r) if r.version == inner.version => r,
1646        // A negotiated endpoint that drifts to another version is an
1647        // identity mismatch, never an upgrade or downgrade (spec 020, 3.7).
1648        Ok(r) => {
1649            return all(
1650                RemoteCode::IdentityMismatch,
1651                format!(
1652                    "protocol version {} where {} was negotiated",
1653                    r.version, inner.version
1654                ),
1655                None,
1656            );
1657        }
1658        Err(e) => return all(RemoteCode::MalformedResponse, e.to_string(), None),
1659    };
1660    record.route = resp.route.clone();
1661    record.generation_id = resp.generation_id.clone();
1662    record.provider_extras = resp.extras.clone();
1663    let terms = &inner.remote.terms;
1664    let rate = inner.config.unit_rate;
1665    let total = convert_charge(resp.charge, rate);
1666    record.reported_cost = match resp.charge {
1667        Charge::Observed { units } | Charge::Estimated { units } => {
1668            Some(rustev_contract::remote::ReportedCost {
1669                amount: units.to_string(),
1670                currency: "remote-units".into(),
1671            })
1672        }
1673        Charge::Unknown => None,
1674    };
1675    record.exchange_charge = total;
1676    let reported = (resp.charge != Charge::Unknown).then_some(total);
1677    let served = match served_identity(&terms.served, resp.served.identity.as_deref()) {
1678        Ok(s) => s,
1679        Err(m) => {
1680            record.served = ServedIdentity {
1681                identity: Some(m.served.clone()),
1682                disclosure: Disclosure::Reported,
1683            };
1684            return all(
1685                RemoteCode::IdentityMismatch,
1686                format!("served `{}` where `{}` is pinned", m.served, m.pinned),
1687                reported,
1688            );
1689        }
1690    };
1691    // A name offered under `unknown` terms is kept as a provider extra.
1692    if served.disclosure == Disclosure::Unknown
1693        && let Some(name) = &resp.served.identity
1694    {
1695        record
1696            .provider_extras
1697            .insert("served_identity_claim".into(), name.clone());
1698    }
1699    record.served = served;
1700    if resp.artifact != inner.remote.descriptor.artifact {
1701        return all(
1702            RemoteCode::IdentityMismatch,
1703            "the remote artifact differs from the negotiated one".into(),
1704            reported,
1705        );
1706    }
1707    // Echo checks (3.5.2): each requested id answered exactly once, and no
1708    // answer for an id that was not asked.
1709    let mut counts: HashMap<&str, usize> = HashMap::new();
1710    for a in &resp.items {
1711        *counts.entry(a.attempt_id.as_str()).or_default() += 1;
1712    }
1713    let asked: HashSet<&str> = request
1714        .items
1715        .iter()
1716        .map(|i| i.attempt_id.as_str())
1717        .collect();
1718    if counts.keys().any(|k| !asked.contains(k)) {
1719        return all(
1720            RemoteCode::MalformedResponse,
1721            "an answer names an attempt that was not asked".into(),
1722            reported,
1723        );
1724    }
1725    request
1726        .items
1727        .iter()
1728        .zip(kinds)
1729        .map(|(item, kind)| {
1730            let mut d = Determined {
1731                outcome: Err((RemoteCode::MalformedResponse, String::new())),
1732                share: reported,
1733                in_progress: false,
1734            };
1735            match counts.get(item.attempt_id.as_str()).copied().unwrap_or(0) {
1736                0 => d.outcome = Err((RemoteCode::MalformedResponse, "no answer".into())),
1737                1 => {}
1738                _ => {
1739                    d.outcome = Err((RemoteCode::MalformedResponse, "answered twice".into()));
1740                    return d;
1741                }
1742            }
1743            let Some(a) = resp.items.iter().find(|a| a.attempt_id == item.attempt_id) else {
1744                return d;
1745            };
1746            let declared = Some(kind.clone());
1747            d.outcome = match &a.result {
1748                ItemResult::Output(o) if output_kind_of(o) == declared => Ok(o.clone()),
1749                ItemResult::Output(o) => Err((
1750                    RemoteCode::MalformedResponse,
1751                    format!(
1752                        "a {} answer where {:?} is declared",
1753                        output_kind_of(o)
1754                            .map(|kind| format!("{kind:?}"))
1755                            .unwrap_or_else(|| "TextRecord".into()),
1756                        declared
1757                    ),
1758                )),
1759                // Only the adapter observes its transport; a remote party
1760                // sending such a code is not trusted to zero a charge.
1761                ItemResult::Failure { code, .. } if is_local_only(*code) => Err((
1762                    RemoteCode::MalformedResponse,
1763                    format!("the adapter-only code `{}` in an answer", code.as_str()),
1764                )),
1765                ItemResult::Failure { code, detail } => Err((*code, detail.clone())),
1766                ItemResult::InProgress => {
1767                    d.in_progress = true;
1768                    Err((RemoteCode::RemoteError, "in_progress".into()))
1769                }
1770            };
1771            d
1772        })
1773        .collect()
1774}
1775
1776/// Deliver results to the members still waiting, attribute the charge,
1777/// offer late charges, and write the exchange record.
1778fn finish(
1779    inner: &Inner,
1780    ex: &Exchange,
1781    det: Vec<Determined>,
1782    live_only: bool,
1783    mut record: rustev_contract::remote::ExchangeRecord,
1784    request: Vec<u8>,
1785    response: Option<Vec<u8>>,
1786) {
1787    let terms = &inner.remote.terms;
1788    let sent = record.bytes_sent;
1789    let mut offers = vec![];
1790    // Reports are sent after the record reaches the sink, so a caller that
1791    // has its result can already find the exchange recorded.
1792    let mut reports = vec![];
1793    {
1794        let mut st = lock(&ex.state);
1795        st.finished = Some(response.is_some());
1796        if response.is_none() {
1797            // No response carries a charge: a confirmed cancel is the only
1798            // final charge known for a member that left.
1799            for m in &mut st.members {
1800                if let (Status::Abandoned, false, Some(Charge::Observed { units })) =
1801                    (m.status, m.settled, m.confirmed)
1802                {
1803                    m.settled = true;
1804                    offers.push(ReconciliationOffer {
1805                        attempt_id: m.attempt_id.clone(),
1806                        observed_units: units,
1807                        binding: inner.binding.clone(),
1808                    });
1809                }
1810            }
1811        }
1812        // With `live_only`, a member whose own budget ran out is left to
1813        // answer its signal; the others are answered now.
1814        let receives =
1815            |m: &Member| m.status == Status::Waiting && !(live_only && m.budget.expired());
1816        let received: Vec<bool> = st.members.iter().map(receives).collect();
1817        let any_received = received.iter().any(|r| *r);
1818        let mut members = vec![];
1819        {
1820            let reported = det.first().and_then(|d| d.share);
1821            // Shares go to the members that receive the response; with
1822            // none, the whole charge is offered to those that left.
1823            let shares = match reported {
1824                Some(total) if any_received => attribute(total, &received),
1825                Some(total) => attribute(total, &vec![true; received.len()]),
1826                None => vec![Charge::Unknown; received.len()],
1827            };
1828            record.late = !any_received && response.is_some();
1829            for ((m, d), share) in st.members.iter_mut().zip(det).zip(shares) {
1830                let waiting = receives(m);
1831                let report = match &d.outcome {
1832                    Ok(o) => AttemptReport {
1833                        result: Ok(o.clone()),
1834                        charge: share,
1835                        cancel: CancelAck::NotRequested,
1836                        remote: RemoteEnd::Finished,
1837                    },
1838                    // A remote cancel this member never asked for.
1839                    Err((RemoteCode::Cancelled, detail)) => RemoteFailure::new(
1840                        RemoteCode::RemoteError,
1841                        format!("cancelled at the remote side without a request: {detail}"),
1842                        d.share.map(|_| share),
1843                        terms.refusals_charged,
1844                        sent,
1845                    )
1846                    .report(),
1847                    Err((code, detail)) => {
1848                        let reported = d.share.map(|_| share);
1849                        let mut f = RemoteFailure::new(
1850                            *code,
1851                            detail.clone(),
1852                            reported,
1853                            terms.refusals_charged,
1854                            sent,
1855                        );
1856                        if d.in_progress {
1857                            f.charge = Charge::Unknown;
1858                            f.remote = RemoteEnd::PossiblyContinuing;
1859                        }
1860                        f.report()
1861                    }
1862                };
1863                members.push(ExchangeMember {
1864                    attempt_id: m.attempt_id.clone(),
1865                    outcome: match &d.outcome {
1866                        Ok(o) => MemberOutcome::Output {
1867                            digest: output_digest(o).unwrap_or_default(),
1868                        },
1869                        Err((code, detail)) => MemberOutcome::Failure {
1870                            code: *code,
1871                            detail: detail.clone(),
1872                        },
1873                    },
1874                    charge: if waiting || record.late {
1875                        report.charge
1876                    } else {
1877                        Charge::Unknown
1878                    },
1879                });
1880                if waiting {
1881                    m.status = Status::Delivered;
1882                    m.settled = true;
1883                    if let Some(tx) = m.tx.take() {
1884                        reports.push((tx, report));
1885                    }
1886                } else if m.status == Status::Abandoned && !m.settled && response.is_some() {
1887                    // A charge learned after the member stopped waiting
1888                    // is offered, never supplied (3.4.4, 3.10.4).
1889                    let offered = if any_received {
1890                        // Fully attributed to the receivers.
1891                        matches!(reported, Some(Charge::Observed { .. })).then_some(0)
1892                    } else {
1893                        match share {
1894                            Charge::Observed { units } => Some(units),
1895                            _ => None,
1896                        }
1897                    };
1898                    if m.awaiting {
1899                        m.late_share = offered;
1900                    } else if let Some(units) = offered {
1901                        m.settled = true;
1902                        offers.push(ReconciliationOffer {
1903                            attempt_id: m.attempt_id.clone(),
1904                            observed_units: units,
1905                            binding: inner.binding.clone(),
1906                        });
1907                    }
1908                }
1909            }
1910        }
1911        record.members = members;
1912    }
1913    for o in offers {
1914        inner.exchanges.offer(o);
1915    }
1916    let retained = RetainedBytes { request, response };
1917    let (members, served, sent_any, batch_id) = (
1918        record.members.clone(),
1919        record.served.clone(),
1920        record.bytes_sent,
1921        ex.batch_id
1922            .clone()
1923            .unwrap_or_else(|| record.request_digest.clone().unwrap_or_default()),
1924    );
1925    let note = inner.exchanges.deliver(record, Some(retained));
1926    if inner.config.run_evidence {
1927        // Evidence is complete before any member has its answer.
1928        let exchange = match (note.digest, note.located) {
1929            (Some(digest), Ok(Some(locator))) => ExchangeReference::Retained {
1930                digest,
1931                schema: rustev_contract::schema::REMOTE_EXCHANGE.into(),
1932                retention: if inner.exchanges.retains_bytes() {
1933                    RetentionClass::BytesRetained
1934                } else {
1935                    RetentionClass::DigestOnly
1936                },
1937                locator,
1938            },
1939            (_, Ok(None)) => ExchangeReference::Unavailable {
1940                reason: EvidenceUnavailable::NoLocator,
1941            },
1942            _ => ExchangeReference::Unavailable {
1943                reason: EvidenceUnavailable::NotDelivered,
1944            },
1945        };
1946        let n = members.len();
1947        let mut store = lock(&inner.evidence);
1948        for (index, m) in members.iter().enumerate() {
1949            if let Some(e) = store.map.get_mut(&m.attempt_id) {
1950                e.exchange = exchange.clone();
1951                e.served = served.clone();
1952                e.code = match &m.outcome {
1953                    MemberOutcome::Failure { code, .. } => Some(*code),
1954                    MemberOutcome::Output { .. } => None,
1955                };
1956                e.charge_provenance = match m.charge {
1957                    _ if !sent_any => ChargeProvenance::NothingSent,
1958                    Charge::Unknown => ChargeProvenance::Unknown,
1959                    _ if n > 1 => ChargeProvenance::Attributed,
1960                    _ => ChargeProvenance::Reported,
1961                };
1962                e.batch = (n > 1).then(|| BatchMembership {
1963                    batch_id: batch_id.clone(),
1964                    index: index as u32,
1965                });
1966            }
1967        }
1968    }
1969    for (tx, report) in reports {
1970        let _ = tx.send(report);
1971    }
1972}
1973
1974/// Re-run this client's mapping offline over retained response bytes
1975/// (3.9.4): the output supplied for `attempt_id`, when the response carried
1976/// one of the `declared` kind, else `None`. This checks the mapping, not
1977/// the model; it makes no remote call.
1978pub fn map_retained(body: &[u8], attempt_id: &str, declared: OutputKind) -> Option<RawOutput> {
1979    let resp = InferResponse::parse(body).ok()?;
1980    protocol_name(resp.version)?;
1981    let mut answers = resp.items.iter().filter(|a| a.attempt_id == attempt_id);
1982    let one = answers.next()?;
1983    if answers.next().is_some() {
1984        return None;
1985    }
1986    match &one.result {
1987        ItemResult::Output(o) if output_kind_of(o).as_ref() == Some(&declared) => Some(o.clone()),
1988        _ => None,
1989    }
1990}
1991
1992#[cfg(test)]
1993mod tests {
1994    use super::*;
1995
1996    #[test]
1997    fn an_aborted_exchange_never_counts_as_sent_even_with_siblings() {
1998        let t = SentTracker::new();
1999        assert!(t.try_abort());
2000        assert!(!bytes_may_leave(&t, true));
2001        assert!(!bytes_may_leave(&t, false));
2002        let idle = SentTracker::new();
2003        assert!(bytes_may_leave(&idle, true), "siblings will send it");
2004        let alone = SentTracker::new();
2005        assert!(!bytes_may_leave(&alone, false), "a lone member aborts");
2006        let writing = SentTracker::new();
2007        assert!(writing.begin_write());
2008        assert!(bytes_may_leave(&writing, false));
2009    }
2010
2011    #[test]
2012    fn a_lost_answer_after_sending_is_possibly_continuing() {
2013        let r = lost(true);
2014        assert_eq!(
2015            (r.charge, r.remote),
2016            (Charge::Unknown, RemoteEnd::PossiblyContinuing)
2017        );
2018        let r = lost(false);
2019        assert_eq!(
2020            (r.charge, r.remote),
2021            (Charge::Observed { units: 0 }, RemoteEnd::Finished)
2022        );
2023    }
2024}