Skip to main content

termivar_scanner/
runtime_budget.rs

1//! Resource envelope for host-facing decision runtimes.
2//!
3//! ## Runtime scope
4//!
5//! - **Build:** default via `scanning`.
6//! - **Execution:** Surface B (budget for the decision runtime).
7//! - **Default `termivar scan`:** yes, through `StandardWebDecisionRuntime`.
8//! - **Support:** implemented and tested.
9//!
10//! See `docs/internals/runtime-map.md`.
11//!
12//! These contracts deliberately live above planning, reasoning, verification,
13//! and experience. Domain layers describe what should happen; the runtime owns
14//! whether another side effect is still permitted.
15
16use std::{
17    collections::BTreeMap,
18    fmt,
19    sync::{Arc, Mutex, MutexGuard},
20    time::{Duration, Instant},
21};
22
23use serde::{Deserialize, Serialize};
24
25/// Default maximum number of HTTP requests in one runtime session.
26pub const DEFAULT_MAX_TOTAL_REQUESTS: u32 = 32;
27/// Default wall-clock deadline in milliseconds.
28pub const DEFAULT_MAX_WALL_TIME_MS: u64 = 120_000;
29/// Default cumulative transport-delivered response-body threshold.
30pub const DEFAULT_MAX_RESPONSE_BYTES: u64 = 2 * 1024 * 1024;
31/// Default cumulative number of request-body bytes dispatched by the broker.
32pub const DEFAULT_MAX_REQUEST_BODY_BYTES: u64 = 256 * 1024;
33/// Default maximum number of active verification requests.
34pub const DEFAULT_MAX_ACTIVE_VERIFICATIONS: u16 = 4;
35/// Default maximum attempts for one semantic action.
36pub const DEFAULT_MAX_SAME_ACTION_ATTEMPTS: u16 = 3;
37/// Default maximum consecutive completed turns without semantic progress.
38pub const DEFAULT_MAX_CONSECUTIVE_NO_PROGRESS_TURNS: u16 = 4;
39/// Maximum dispatch receipts retained by one broker audit.
40pub const HARD_MAX_TRANSPORT_DISPATCH_RECEIPTS: usize = 4_096;
41
42/// Multi-dimensional resource envelope for one runtime session.
43///
44/// Zero is a valid fail-closed value for every dimension. For example,
45/// `max_total_requests == 0` prevents even bootstrap I/O.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(default, deny_unknown_fields)]
48pub struct RuntimeBudget {
49    max_total_requests: u32,
50    max_wall_time_ms: u64,
51    max_response_bytes: u64,
52    max_request_body_bytes: u64,
53    max_active_verifications: u16,
54    max_same_action_attempts: u16,
55    max_consecutive_no_progress_turns: u16,
56}
57
58impl RuntimeBudget {
59    /// Creates an explicit resource envelope.
60    pub const fn new(
61        max_total_requests: u32,
62        max_wall_time_ms: u64,
63        max_response_bytes: u64,
64        max_active_verifications: u16,
65        max_same_action_attempts: u16,
66        max_consecutive_no_progress_turns: u16,
67    ) -> Self {
68        Self {
69            max_total_requests,
70            max_wall_time_ms,
71            max_response_bytes,
72            max_request_body_bytes: DEFAULT_MAX_REQUEST_BODY_BYTES,
73            max_active_verifications,
74            max_same_action_attempts,
75            max_consecutive_no_progress_turns,
76        }
77    }
78
79    /// Returns the maximum number of requests, including bootstrap and retries.
80    pub const fn max_total_requests(self) -> u32 {
81        self.max_total_requests
82    }
83
84    /// Returns the monotonic wall-clock limit.
85    pub const fn max_wall_time(self) -> Duration {
86        Duration::from_millis(self.max_wall_time_ms)
87    }
88
89    /// Returns the serialized wall-clock limit in milliseconds.
90    pub const fn max_wall_time_ms(self) -> u64 {
91        self.max_wall_time_ms
92    }
93
94    /// Returns the cumulative transport-delivered response-body threshold.
95    ///
96    /// The chunk that crosses this threshold is charged in full and surfaced
97    /// as a typed limit; the broker starts no later body read.
98    pub const fn max_response_bytes(self) -> u64 {
99        self.max_response_bytes
100    }
101
102    /// Returns the cumulative request-body byte limit.
103    ///
104    /// Headers and transport framing are not included. A body is charged
105    /// atomically immediately before its request is dispatched.
106    pub const fn max_request_body_bytes(self) -> u64 {
107        self.max_request_body_bytes
108    }
109
110    /// Returns the maximum number of active verification requests.
111    pub const fn max_active_verifications(self) -> u16 {
112        self.max_active_verifications
113    }
114
115    /// Returns the maximum number of attempts for one semantic action.
116    pub const fn max_same_action_attempts(self) -> u16 {
117        self.max_same_action_attempts
118    }
119
120    /// Returns the maximum consecutive completed no-progress turns.
121    pub const fn max_consecutive_no_progress_turns(self) -> u16 {
122        self.max_consecutive_no_progress_turns
123    }
124
125    /// Replaces the total-request limit.
126    pub const fn with_max_total_requests(mut self, limit: u32) -> Self {
127        self.max_total_requests = limit;
128        self
129    }
130
131    /// Replaces the wall-clock limit, saturating at the wire representation.
132    pub fn with_max_wall_time(mut self, limit: Duration) -> Self {
133        let millis = limit.as_millis();
134        let rounded = if limit.is_zero() { 0 } else { millis.max(1) };
135        self.max_wall_time_ms = u64::try_from(rounded).unwrap_or(u64::MAX);
136        self
137    }
138
139    /// Replaces the cumulative transport-delivered response-body threshold.
140    pub const fn with_max_response_bytes(mut self, limit: u64) -> Self {
141        self.max_response_bytes = limit;
142        self
143    }
144
145    /// Replaces the cumulative request-body byte limit.
146    pub const fn with_max_request_body_bytes(mut self, limit: u64) -> Self {
147        self.max_request_body_bytes = limit;
148        self
149    }
150
151    /// Replaces the active-verification request limit.
152    pub const fn with_max_active_verifications(mut self, limit: u16) -> Self {
153        self.max_active_verifications = limit;
154        self
155    }
156
157    /// Replaces the per-action attempt limit.
158    pub const fn with_max_same_action_attempts(mut self, limit: u16) -> Self {
159        self.max_same_action_attempts = limit;
160        self
161    }
162
163    /// Replaces the consecutive no-progress turn limit.
164    pub const fn with_max_consecutive_no_progress_turns(mut self, limit: u16) -> Self {
165        self.max_consecutive_no_progress_turns = limit;
166        self
167    }
168}
169
170impl Default for RuntimeBudget {
171    fn default() -> Self {
172        Self::new(
173            DEFAULT_MAX_TOTAL_REQUESTS,
174            DEFAULT_MAX_WALL_TIME_MS,
175            DEFAULT_MAX_RESPONSE_BYTES,
176            DEFAULT_MAX_ACTIVE_VERIFICATIONS,
177            DEFAULT_MAX_SAME_ACTION_ATTEMPTS,
178            DEFAULT_MAX_CONSECUTIVE_NO_PROGRESS_TURNS,
179        )
180    }
181}
182
183/// Resource dimension that stopped a runtime before its next side effect.
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
185#[serde(rename_all = "snake_case")]
186#[non_exhaustive]
187pub enum RuntimeBudgetDimension {
188    /// Total bootstrap, passive, active, adaptive, and retry requests.
189    TotalRequests,
190    /// Monotonic time spent by the complete runtime.
191    WallTime,
192    /// Cumulative response-body bytes delivered by transport, including the
193    /// single serialized chunk that can cross the configured threshold.
194    ResponseBytes,
195    /// Cumulative request-body bytes accepted for transport dispatch.
196    RequestBodyBytes,
197    /// Total explicit active-verification requests.
198    ActiveVerifications,
199    /// Attempts made for one semantic action identity.
200    SameActionAttempts,
201    /// Consecutive completed execution turns without semantic progress.
202    ConsecutiveNoProgressTurns,
203}
204
205impl fmt::Display for RuntimeBudgetDimension {
206    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
207        formatter.write_str(match self {
208            Self::TotalRequests => "total_requests",
209            Self::WallTime => "wall_time_ms",
210            Self::ResponseBytes => "response_bytes",
211            Self::RequestBodyBytes => "request_body_bytes",
212            Self::ActiveVerifications => "active_verifications",
213            Self::SameActionAttempts => "same_action_attempts",
214            Self::ConsecutiveNoProgressTurns => "consecutive_no_progress_turns",
215        })
216    }
217}
218
219/// Structured explanation of a fail-closed runtime stop.
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
221pub struct RuntimeLimitExceeded {
222    dimension: RuntimeBudgetDimension,
223    limit: u64,
224    observed: u64,
225    action_id: Option<String>,
226}
227
228impl RuntimeLimitExceeded {
229    /// Creates a limit record for the attempted operation.
230    pub(crate) fn new(
231        dimension: RuntimeBudgetDimension,
232        limit: u64,
233        observed: u64,
234        action_id: Option<String>,
235    ) -> Self {
236        Self {
237            dimension,
238            limit,
239            observed,
240            action_id,
241        }
242    }
243
244    /// Returns the exhausted resource dimension.
245    pub const fn dimension(&self) -> RuntimeBudgetDimension {
246        self.dimension
247    }
248
249    /// Returns the configured maximum.
250    pub const fn limit(&self) -> u64 {
251        self.limit
252    }
253
254    /// Returns the measured or next-attempt value that reached the guard.
255    pub const fn observed(&self) -> u64 {
256        self.observed
257    }
258
259    /// Returns the affected semantic action for an action-scoped limit.
260    pub fn action_id(&self) -> Option<&str> {
261        self.action_id.as_deref()
262    }
263}
264
265impl fmt::Display for RuntimeLimitExceeded {
266    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
267        write!(
268            formatter,
269            "runtime {} limit {} reached by {}",
270            self.dimension, self.limit, self.observed
271        )?;
272        if let Some(action_id) = &self.action_id {
273            write!(formatter, " for action {action_id}")?;
274        }
275        Ok(())
276    }
277}
278
279impl std::error::Error for RuntimeLimitExceeded {}
280
281/// Terminal transport state for one broker-owned wire dispatch.
282#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
283#[serde(rename_all = "snake_case")]
284#[non_exhaustive]
285pub enum TransportDispatchOutcome {
286    /// The response completed within transport and accounting policy.
287    Completed,
288    /// Request dispatch or response streaming failed at the transport layer.
289    TransportFailure,
290    /// The broker-owned per-request deadline elapsed.
291    RequestTimeout,
292    /// A delivered response chunk crossed the session byte boundary.
293    ResponseBudgetReached,
294    /// The caller dropped the in-flight broker future before classification.
295    Cancelled,
296}
297
298/// Raw-target-free audit receipt for one broker-owned wire dispatch.
299#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
300pub struct TransportDispatchReceipt {
301    sequence: u64,
302    action_id: String,
303    stage: crate::DecisionExecutionStage,
304    #[serde(skip_serializing_if = "Option::is_none")]
305    origin: Option<crate::DecisionActionOrigin>,
306    request_body_bytes: u64,
307    response_bytes: u64,
308    elapsed_ms: u64,
309    outcome: TransportDispatchOutcome,
310}
311
312impl TransportDispatchReceipt {
313    /// Returns the zero-based dispatch order within this broker authority.
314    pub const fn sequence(&self) -> u64 {
315        self.sequence
316    }
317
318    /// Returns the semantic action charged for this dispatch.
319    pub fn action_id(&self) -> &str {
320        &self.action_id
321    }
322
323    /// Returns whether this was passive collection or active verification.
324    pub const fn stage(&self) -> crate::DecisionExecutionStage {
325        self.stage
326    }
327
328    /// Returns the passive action origin, when applicable.
329    pub const fn origin(&self) -> Option<crate::DecisionActionOrigin> {
330        self.origin
331    }
332
333    /// Returns buffered request-body bytes charged before dispatch.
334    pub const fn request_body_bytes(&self) -> u64 {
335        self.request_body_bytes
336    }
337
338    /// Returns complete response-body bytes delivered to the collector.
339    pub const fn response_bytes(&self) -> u64 {
340        self.response_bytes
341    }
342
343    /// Returns monotonic elapsed time from lease acquisition to classification.
344    pub const fn elapsed_ms(&self) -> u64 {
345        self.elapsed_ms
346    }
347
348    /// Returns the broker-owned terminal transport classification.
349    pub const fn outcome(&self) -> TransportDispatchOutcome {
350        self.outcome
351    }
352}
353
354/// Bounded, dispatch-ordered transport audit for one accounting authority.
355#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
356pub struct TransportDispatchAudit {
357    receipts: Vec<TransportDispatchReceipt>,
358    omitted_receipt_count: u64,
359}
360
361impl TransportDispatchAudit {
362    /// Returns retained receipts in original dispatch order.
363    pub fn receipts(&self) -> &[TransportDispatchReceipt] {
364        &self.receipts
365    }
366
367    /// Returns completed dispatch receipts excluded by the hard audit ceiling.
368    pub const fn omitted_receipt_count(&self) -> u64 {
369        self.omitted_receipt_count
370    }
371
372    /// Returns whether no dispatch was recorded or omitted.
373    pub fn is_empty(&self) -> bool {
374        self.receipts.is_empty() && self.omitted_receipt_count == 0
375    }
376}
377
378#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
379pub(crate) struct RequestAccountingSnapshot {
380    total_requests: u32,
381    passive_requests: u32,
382    active_verifications: u16,
383    bootstrap_requests: u32,
384    planned_requests: u32,
385    adaptive_requests: u32,
386    retry_requests: u32,
387    request_body_bytes: u64,
388    response_bytes: u64,
389}
390
391impl RequestAccountingSnapshot {
392    pub(crate) const fn total_requests(self) -> u32 {
393        self.total_requests
394    }
395
396    pub(crate) const fn passive_requests(self) -> u32 {
397        self.passive_requests
398    }
399
400    pub(crate) const fn active_verifications(self) -> u16 {
401        self.active_verifications
402    }
403
404    pub(crate) const fn bootstrap_requests(self) -> u32 {
405        self.bootstrap_requests
406    }
407
408    pub(crate) const fn planned_requests(self) -> u32 {
409        self.planned_requests
410    }
411
412    pub(crate) const fn adaptive_requests(self) -> u32 {
413        self.adaptive_requests
414    }
415
416    pub(crate) const fn retry_requests(self) -> u32 {
417        self.retry_requests
418    }
419
420    pub(crate) const fn request_body_bytes(self) -> u64 {
421        self.request_body_bytes
422    }
423
424    pub(crate) const fn response_bytes(self) -> u64 {
425        self.response_bytes
426    }
427}
428
429#[derive(Debug, Clone, Copy, PartialEq, Eq)]
430pub(crate) struct RequestAccountingPreflight {
431    remaining_response_bytes: u64,
432}
433
434impl RequestAccountingPreflight {
435    pub(crate) const fn remaining_response_bytes(self) -> u64 {
436        self.remaining_response_bytes
437    }
438}
439
440#[derive(Debug, Default)]
441struct RequestAccountingState {
442    snapshot: RequestAccountingSnapshot,
443    next_dispatch_sequence: u64,
444    dispatch_receipts: Vec<TransportDispatchReceipt>,
445    omitted_dispatch_receipts: u64,
446}
447
448/// Shared host-owned authority for logical transport dispatch accounting.
449///
450/// Clones share one monotonic state. Preflight is advisory and side-effect
451/// free; [`Self::try_begin`] repeats every guard under the same state lock that
452/// records the dispatch.
453#[derive(Debug, Clone)]
454pub(crate) struct RequestAccountingBroker {
455    budget: RuntimeBudget,
456    state: Arc<Mutex<RequestAccountingState>>,
457    response_read_gate: Arc<tokio::sync::Mutex<()>>,
458}
459
460impl RequestAccountingBroker {
461    pub(crate) fn new(budget: RuntimeBudget) -> Self {
462        Self {
463            budget,
464            state: Arc::new(Mutex::new(RequestAccountingState::default())),
465            response_read_gate: Arc::new(tokio::sync::Mutex::new(())),
466        }
467    }
468
469    #[cfg(test)]
470    pub(crate) const fn budget(&self) -> RuntimeBudget {
471        self.budget
472    }
473
474    pub(crate) fn snapshot(&self) -> RequestAccountingSnapshot {
475        self.lock_state().snapshot
476    }
477
478    pub(crate) fn dispatch_audit(&self) -> TransportDispatchAudit {
479        let state = self.lock_state();
480        let mut receipts = state.dispatch_receipts.clone();
481        receipts.sort_by_key(TransportDispatchReceipt::sequence);
482        TransportDispatchAudit {
483            receipts,
484            omitted_receipt_count: state.omitted_dispatch_receipts,
485        }
486    }
487
488    pub(crate) fn preflight(
489        &self,
490        action_id: &str,
491        stage: crate::DecisionExecutionStage,
492    ) -> Result<RequestAccountingPreflight, RuntimeLimitExceeded> {
493        self.preflight_with_request_body_bytes(action_id, stage, 0)
494    }
495
496    pub(crate) fn preflight_with_request_body_bytes(
497        &self,
498        action_id: &str,
499        stage: crate::DecisionExecutionStage,
500        request_body_bytes: u64,
501    ) -> Result<RequestAccountingPreflight, RuntimeLimitExceeded> {
502        check_request_limits(
503            &self.lock_state().snapshot,
504            self.budget,
505            action_id,
506            stage,
507            request_body_bytes,
508        )
509    }
510
511    #[cfg(test)]
512    pub(crate) fn try_begin(
513        &self,
514        action_id: &str,
515        stage: crate::DecisionExecutionStage,
516        origin: Option<crate::DecisionActionOrigin>,
517    ) -> Result<RequestAccountingLease, RuntimeLimitExceeded> {
518        self.try_begin_with_request_body_bytes(action_id, stage, origin, 0)
519    }
520
521    pub(crate) fn try_begin_with_request_body_bytes(
522        &self,
523        action_id: &str,
524        stage: crate::DecisionExecutionStage,
525        origin: Option<crate::DecisionActionOrigin>,
526        request_body_bytes: u64,
527    ) -> Result<RequestAccountingLease, RuntimeLimitExceeded> {
528        let mut state = self.lock_state();
529        check_request_limits(
530            &state.snapshot,
531            self.budget,
532            action_id,
533            stage,
534            request_body_bytes,
535        )?;
536        record_dispatch(&mut state.snapshot, stage, origin, request_body_bytes);
537        let sequence = state.next_dispatch_sequence;
538        state.next_dispatch_sequence = state.next_dispatch_sequence.saturating_add(1);
539        drop(state);
540
541        Ok(RequestAccountingLease {
542            broker: self.clone(),
543            sequence,
544            action_id: action_id.to_owned(),
545            stage,
546            origin,
547            request_body_bytes,
548            response_bytes: 0,
549            response_budget_reached: false,
550            started_at: Instant::now(),
551            outcome: None,
552        })
553    }
554
555    fn record_dispatch_receipt(&self, receipt: TransportDispatchReceipt) {
556        let mut state = self.lock_state();
557        if state.dispatch_receipts.len() < HARD_MAX_TRANSPORT_DISPATCH_RECEIPTS {
558            state.dispatch_receipts.push(receipt);
559        } else {
560            state.omitted_dispatch_receipts = state.omitted_dispatch_receipts.saturating_add(1);
561        }
562    }
563
564    fn lock_state(&self) -> MutexGuard<'_, RequestAccountingState> {
565        self.state
566            .lock()
567            .unwrap_or_else(std::sync::PoisonError::into_inner)
568    }
569}
570
571/// Non-clone proof that one logical transport dispatch was recorded.
572///
573/// Observed response bytes are recorded globally immediately. The returned
574/// retention allowance can be smaller than the received chunk, but every byte
575/// already delivered by transport remains charged. Dropping the lease never
576/// rolls back either the request or byte counters.
577#[derive(Debug)]
578pub(crate) struct RequestAccountingLease {
579    broker: RequestAccountingBroker,
580    sequence: u64,
581    action_id: String,
582    stage: crate::DecisionExecutionStage,
583    origin: Option<crate::DecisionActionOrigin>,
584    request_body_bytes: u64,
585    response_bytes: u64,
586    response_budget_reached: bool,
587    started_at: Instant,
588    outcome: Option<TransportDispatchOutcome>,
589}
590
591impl RequestAccountingLease {
592    pub(crate) async fn acquire_response_read(&self) -> tokio::sync::OwnedMutexGuard<()> {
593        Arc::clone(&self.broker.response_read_gate)
594            .lock_owned()
595            .await
596    }
597
598    pub(crate) fn observe_response_bytes(&mut self, bytes: u64) -> u64 {
599        self.response_bytes = self.response_bytes.saturating_add(bytes);
600        let mut state = self.broker.lock_state();
601        let remaining = self
602            .broker
603            .budget
604            .max_response_bytes()
605            .saturating_sub(state.snapshot.response_bytes);
606        if bytes > 0 && bytes >= remaining {
607            self.response_budget_reached = true;
608        }
609        let retained = bytes.min(remaining);
610        state.snapshot.response_bytes = state.snapshot.response_bytes.saturating_add(bytes);
611        retained
612    }
613
614    pub(crate) fn remaining_response_bytes(&self) -> u64 {
615        let response_bytes = self.broker.snapshot().response_bytes;
616        self.broker
617            .budget
618            .max_response_bytes()
619            .saturating_sub(response_bytes)
620    }
621
622    pub(crate) const fn response_budget_reached(&self) -> bool {
623        self.response_budget_reached
624    }
625
626    pub(crate) fn finish(&mut self, outcome: TransportDispatchOutcome) {
627        debug_assert!(self.outcome.is_none(), "dispatch outcome classified twice");
628        self.outcome = Some(outcome);
629    }
630}
631
632impl Drop for RequestAccountingLease {
633    fn drop(&mut self) {
634        let receipt = TransportDispatchReceipt {
635            sequence: self.sequence,
636            action_id: self.action_id.clone(),
637            stage: self.stage,
638            origin: self.origin,
639            request_body_bytes: self.request_body_bytes,
640            response_bytes: self.response_bytes,
641            elapsed_ms: u64::try_from(self.started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
642            outcome: self.outcome.unwrap_or(TransportDispatchOutcome::Cancelled),
643        };
644        self.broker.record_dispatch_receipt(receipt);
645    }
646}
647
648fn check_request_limits(
649    snapshot: &RequestAccountingSnapshot,
650    budget: RuntimeBudget,
651    action_id: &str,
652    stage: crate::DecisionExecutionStage,
653    request_body_bytes: u64,
654) -> Result<RequestAccountingPreflight, RuntimeLimitExceeded> {
655    if snapshot.total_requests >= budget.max_total_requests() {
656        return Err(RuntimeLimitExceeded::new(
657            RuntimeBudgetDimension::TotalRequests,
658            u64::from(budget.max_total_requests()),
659            u64::from(snapshot.total_requests).saturating_add(1),
660            Some(action_id.to_owned()),
661        ));
662    }
663    if snapshot.response_bytes >= budget.max_response_bytes() {
664        return Err(RuntimeLimitExceeded::new(
665            RuntimeBudgetDimension::ResponseBytes,
666            budget.max_response_bytes(),
667            snapshot.response_bytes,
668            Some(action_id.to_owned()),
669        ));
670    }
671    let Some(next_request_body_bytes) = snapshot.request_body_bytes.checked_add(request_body_bytes)
672    else {
673        return Err(RuntimeLimitExceeded::new(
674            RuntimeBudgetDimension::RequestBodyBytes,
675            budget.max_request_body_bytes(),
676            u64::MAX,
677            Some(action_id.to_owned()),
678        ));
679    };
680    if next_request_body_bytes > budget.max_request_body_bytes() {
681        return Err(RuntimeLimitExceeded::new(
682            RuntimeBudgetDimension::RequestBodyBytes,
683            budget.max_request_body_bytes(),
684            next_request_body_bytes,
685            Some(action_id.to_owned()),
686        ));
687    }
688    if stage == crate::DecisionExecutionStage::Active
689        && snapshot.active_verifications >= budget.max_active_verifications()
690    {
691        return Err(RuntimeLimitExceeded::new(
692            RuntimeBudgetDimension::ActiveVerifications,
693            u64::from(budget.max_active_verifications()),
694            u64::from(snapshot.active_verifications).saturating_add(1),
695            Some(action_id.to_owned()),
696        ));
697    }
698
699    Ok(RequestAccountingPreflight {
700        remaining_response_bytes: budget
701            .max_response_bytes()
702            .saturating_sub(snapshot.response_bytes),
703    })
704}
705
706fn record_dispatch(
707    snapshot: &mut RequestAccountingSnapshot,
708    stage: crate::DecisionExecutionStage,
709    origin: Option<crate::DecisionActionOrigin>,
710    request_body_bytes: u64,
711) {
712    snapshot.total_requests = snapshot.total_requests.saturating_add(1);
713    snapshot.request_body_bytes = snapshot
714        .request_body_bytes
715        .saturating_add(request_body_bytes);
716    match stage {
717        crate::DecisionExecutionStage::Passive => {
718            snapshot.passive_requests = snapshot.passive_requests.saturating_add(1);
719            match origin {
720                Some(crate::DecisionActionOrigin::Bootstrap) => {
721                    snapshot.bootstrap_requests = snapshot.bootstrap_requests.saturating_add(1);
722                },
723                Some(crate::DecisionActionOrigin::Planned) => {
724                    snapshot.planned_requests = snapshot.planned_requests.saturating_add(1);
725                },
726                Some(crate::DecisionActionOrigin::Adaptive) => {
727                    snapshot.adaptive_requests = snapshot.adaptive_requests.saturating_add(1);
728                },
729                Some(crate::DecisionActionOrigin::Retry) => {
730                    snapshot.retry_requests = snapshot.retry_requests.saturating_add(1);
731                },
732                None => {},
733            }
734        },
735        crate::DecisionExecutionStage::Active => {
736            snapshot.active_verifications = snapshot.active_verifications.saturating_add(1);
737        },
738    }
739}
740
741/// Monotonic, output-only resource accounting for a runtime session.
742#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
743pub struct RuntimeUsage {
744    total_requests: u32,
745    passive_requests: u32,
746    active_verifications: u16,
747    bootstrap_requests: u32,
748    planned_requests: u32,
749    adaptive_requests: u32,
750    retry_requests: u32,
751    request_body_bytes: u64,
752    response_bytes: u64,
753    completed_execution_turns: u32,
754    consecutive_no_progress_turns: u16,
755    same_action_attempts: BTreeMap<String, u16>,
756    elapsed_ms: u64,
757}
758
759impl RuntimeUsage {
760    /// Returns logical transport dispatches that acquired an accounting lease.
761    pub const fn total_requests(&self) -> u32 {
762        self.total_requests
763    }
764
765    /// Returns passive transport dispatches, including bootstrap, planned,
766    /// adaptive, and retry origins.
767    pub const fn passive_requests(&self) -> u32 {
768        self.passive_requests
769    }
770
771    /// Returns explicit active-verification transport dispatches.
772    pub const fn active_verifications(&self) -> u16 {
773        self.active_verifications
774    }
775
776    /// Returns bootstrap-originated transport dispatches.
777    pub const fn bootstrap_requests(&self) -> u32 {
778        self.bootstrap_requests
779    }
780
781    /// Returns planner-originated transport dispatches.
782    pub const fn planned_requests(&self) -> u32 {
783        self.planned_requests
784    }
785
786    /// Returns adaptation-originated transport dispatches.
787    pub const fn adaptive_requests(&self) -> u32 {
788        self.adaptive_requests
789    }
790
791    /// Returns retry-originated transport dispatches.
792    pub const fn retry_requests(&self) -> u32 {
793        self.retry_requests
794    }
795
796    /// Returns cumulative request-body bytes charged before dispatch.
797    ///
798    /// Bytes remain charged when transport or verification later fails.
799    pub const fn request_body_bytes(&self) -> u64 {
800        self.request_body_bytes
801    }
802
803    /// Returns cumulative response-body bytes delivered to transport leases.
804    ///
805    /// This can exceed the configured threshold by the single serialized chunk
806    /// that revealed the crossing. The runtime reports that crossing as a
807    /// typed limit and starts no later body read. Bytes remain charged when
808    /// execution later fails or is cancelled.
809    pub const fn response_bytes(&self) -> u64 {
810        self.response_bytes
811    }
812
813    /// Returns completed passive or active execution turns.
814    pub const fn completed_execution_turns(&self) -> u32 {
815        self.completed_execution_turns
816    }
817
818    /// Returns the current consecutive no-progress count.
819    pub const fn consecutive_no_progress_turns(&self) -> u16 {
820        self.consecutive_no_progress_turns
821    }
822
823    /// Returns attempts for one semantic action identity.
824    pub fn same_action_attempts(&self, action_id: &str) -> u16 {
825        self.same_action_attempts
826            .get(action_id)
827            .copied()
828            .unwrap_or(0)
829    }
830
831    /// Returns all action attempt counters in stable action-ID order.
832    pub fn action_attempts(&self) -> &BTreeMap<String, u16> {
833        &self.same_action_attempts
834    }
835
836    /// Returns all semantic action attempts without adding another wire field.
837    pub fn total_action_attempts(&self) -> u64 {
838        self.same_action_attempts
839            .values()
840            .map(|attempts| u64::from(*attempts))
841            .sum()
842    }
843
844    /// Returns elapsed runtime wall time.
845    pub const fn elapsed(&self) -> Duration {
846        Duration::from_millis(self.elapsed_ms)
847    }
848
849    /// Returns elapsed runtime wall time in milliseconds.
850    pub const fn elapsed_ms(&self) -> u64 {
851        self.elapsed_ms
852    }
853
854    pub(crate) fn reserve_action_attempt(&mut self, action_id: &str) {
855        let attempts = self
856            .same_action_attempts
857            .entry(action_id.to_owned())
858            .or_default();
859        *attempts = attempts.saturating_add(1);
860    }
861
862    pub(crate) fn sync_request_accounting(&mut self, snapshot: RequestAccountingSnapshot) {
863        // Broker state is monotonic. Merge rather than replace so a stale
864        // snapshot observed by a concurrent reporter cannot make usage appear
865        // to move backwards.
866        self.total_requests = self.total_requests.max(snapshot.total_requests());
867        self.passive_requests = self.passive_requests.max(snapshot.passive_requests());
868        self.active_verifications = self
869            .active_verifications
870            .max(snapshot.active_verifications());
871        self.bootstrap_requests = self.bootstrap_requests.max(snapshot.bootstrap_requests());
872        self.planned_requests = self.planned_requests.max(snapshot.planned_requests());
873        self.adaptive_requests = self.adaptive_requests.max(snapshot.adaptive_requests());
874        self.retry_requests = self.retry_requests.max(snapshot.retry_requests());
875        self.request_body_bytes = self.request_body_bytes.max(snapshot.request_body_bytes());
876        self.response_bytes = self.response_bytes.max(snapshot.response_bytes());
877    }
878
879    pub(crate) fn record_execution_progress(&mut self, progressed: bool) {
880        self.completed_execution_turns = self.completed_execution_turns.saturating_add(1);
881        if progressed {
882            self.consecutive_no_progress_turns = 0;
883        } else {
884            self.consecutive_no_progress_turns =
885                self.consecutive_no_progress_turns.saturating_add(1);
886        }
887    }
888
889    pub(crate) fn set_elapsed(&mut self, elapsed: Duration) {
890        self.elapsed_ms = u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX);
891    }
892}
893
894#[cfg(test)]
895mod tests {
896    use std::{
897        sync::{Arc, Barrier},
898        thread,
899    };
900
901    use super::*;
902
903    #[test]
904    fn budget_accepts_fail_closed_zero_values_and_round_trips() {
905        let budget = RuntimeBudget::default()
906            .with_max_total_requests(0)
907            .with_max_wall_time(Duration::ZERO)
908            .with_max_response_bytes(0)
909            .with_max_request_body_bytes(0)
910            .with_max_active_verifications(0)
911            .with_max_same_action_attempts(0)
912            .with_max_consecutive_no_progress_turns(0);
913
914        let encoded = serde_json::to_string(&budget).unwrap();
915        assert_eq!(
916            serde_json::from_str::<RuntimeBudget>(&encoded).unwrap(),
917            budget
918        );
919        assert_eq!(budget.max_wall_time(), Duration::ZERO);
920
921        let sub_millisecond =
922            RuntimeBudget::default().with_max_wall_time(Duration::from_micros(999));
923        assert_eq!(sub_millisecond.max_wall_time(), Duration::from_millis(1));
924
925        let partial: RuntimeBudget = serde_json::from_value(serde_json::json!({
926            "max_total_requests": 7
927        }))
928        .unwrap();
929        assert_eq!(partial.max_total_requests(), 7);
930        assert_eq!(partial.max_response_bytes(), DEFAULT_MAX_RESPONSE_BYTES);
931        assert_eq!(
932            partial.max_request_body_bytes(),
933            DEFAULT_MAX_REQUEST_BODY_BYTES
934        );
935        assert!(serde_json::from_value::<RuntimeBudget>(serde_json::json!({
936            "max_total_requets": 7
937        }))
938        .is_err());
939
940        assert_eq!(
941            serde_json::to_value(RuntimeBudget::default()).unwrap(),
942            serde_json::json!({
943                "max_total_requests": DEFAULT_MAX_TOTAL_REQUESTS,
944                "max_wall_time_ms": DEFAULT_MAX_WALL_TIME_MS,
945                "max_response_bytes": DEFAULT_MAX_RESPONSE_BYTES,
946                "max_request_body_bytes": DEFAULT_MAX_REQUEST_BODY_BYTES,
947                "max_active_verifications": DEFAULT_MAX_ACTIVE_VERIFICATIONS,
948                "max_same_action_attempts": DEFAULT_MAX_SAME_ACTION_ATTEMPTS,
949                "max_consecutive_no_progress_turns": DEFAULT_MAX_CONSECUTIVE_NO_PROGRESS_TURNS
950            })
951        );
952    }
953
954    #[test]
955    fn zero_limits_fail_preflight_without_mutating_accounting() {
956        let no_requests =
957            RequestAccountingBroker::new(RuntimeBudget::default().with_max_total_requests(0));
958        let request_error = no_requests
959            .preflight("http.bootstrap", crate::DecisionExecutionStage::Passive)
960            .unwrap_err();
961        assert_eq!(
962            request_error.dimension(),
963            RuntimeBudgetDimension::TotalRequests
964        );
965        assert_eq!(no_requests.snapshot(), RequestAccountingSnapshot::default());
966
967        let no_bytes =
968            RequestAccountingBroker::new(RuntimeBudget::default().with_max_response_bytes(0));
969        let bytes_error = no_bytes
970            .preflight("http.bootstrap", crate::DecisionExecutionStage::Passive)
971            .unwrap_err();
972        assert_eq!(
973            bytes_error.dimension(),
974            RuntimeBudgetDimension::ResponseBytes
975        );
976        assert_eq!(no_bytes.snapshot(), RequestAccountingSnapshot::default());
977
978        let no_request_body =
979            RequestAccountingBroker::new(RuntimeBudget::default().with_max_request_body_bytes(0));
980        let request_body_error = no_request_body
981            .preflight_with_request_body_bytes(
982                "http.bootstrap",
983                crate::DecisionExecutionStage::Passive,
984                1,
985            )
986            .unwrap_err();
987        assert_eq!(
988            request_body_error.dimension(),
989            RuntimeBudgetDimension::RequestBodyBytes
990        );
991        assert_eq!(
992            no_request_body.snapshot(),
993            RequestAccountingSnapshot::default()
994        );
995
996        let no_active =
997            RequestAccountingBroker::new(RuntimeBudget::default().with_max_active_verifications(0));
998        let active_error = no_active
999            .preflight("http.verify", crate::DecisionExecutionStage::Active)
1000            .unwrap_err();
1001        assert_eq!(
1002            active_error.dimension(),
1003            RuntimeBudgetDimension::ActiveVerifications
1004        );
1005        assert_eq!(no_active.snapshot(), RequestAccountingSnapshot::default());
1006    }
1007
1008    #[test]
1009    fn preflight_reports_remaining_bytes_without_reserving_a_dispatch() {
1010        let broker = RequestAccountingBroker::new(
1011            RuntimeBudget::default()
1012                .with_max_total_requests(1)
1013                .with_max_response_bytes(7),
1014        );
1015
1016        let first = broker
1017            .preflight("http.bootstrap", crate::DecisionExecutionStage::Passive)
1018            .unwrap();
1019        let second = broker
1020            .preflight("http.bootstrap", crate::DecisionExecutionStage::Passive)
1021            .unwrap();
1022
1023        assert_eq!(first.remaining_response_bytes(), 7);
1024        assert_eq!(second, first);
1025        assert_eq!(broker.snapshot(), RequestAccountingSnapshot::default());
1026        assert_eq!(broker.budget().max_total_requests(), 1);
1027    }
1028
1029    #[test]
1030    fn dropped_lease_never_refunds_a_dispatch() {
1031        let broker =
1032            RequestAccountingBroker::new(RuntimeBudget::default().with_max_total_requests(1));
1033        let lease = broker
1034            .try_begin(
1035                "http.bootstrap",
1036                crate::DecisionExecutionStage::Passive,
1037                Some(crate::DecisionActionOrigin::Bootstrap),
1038            )
1039            .unwrap();
1040
1041        drop(lease);
1042
1043        let error = broker
1044            .try_begin(
1045                "http.bootstrap",
1046                crate::DecisionExecutionStage::Passive,
1047                Some(crate::DecisionActionOrigin::Bootstrap),
1048            )
1049            .unwrap_err();
1050        assert_eq!(error.dimension(), RuntimeBudgetDimension::TotalRequests);
1051        let snapshot = broker.snapshot();
1052        assert_eq!(snapshot.total_requests(), 1);
1053        assert_eq!(snapshot.passive_requests(), 1);
1054        assert_eq!(snapshot.bootstrap_requests(), 1);
1055    }
1056
1057    #[test]
1058    fn dispatch_audit_preserves_order_and_completed_lease_metadata() {
1059        let broker = RequestAccountingBroker::new(
1060            RuntimeBudget::default()
1061                .with_max_total_requests(2)
1062                .with_max_request_body_bytes(8)
1063                .with_max_response_bytes(8),
1064        );
1065        let mut first = broker
1066            .try_begin_with_request_body_bytes(
1067                "http.control",
1068                crate::DecisionExecutionStage::Passive,
1069                Some(crate::DecisionActionOrigin::Bootstrap),
1070                3,
1071            )
1072            .unwrap();
1073        let mut second = broker
1074            .try_begin_with_request_body_bytes(
1075                "http.candidate",
1076                crate::DecisionExecutionStage::Active,
1077                None,
1078                5,
1079            )
1080            .unwrap();
1081
1082        assert_eq!(first.observe_response_bytes(2), 2);
1083        assert_eq!(second.observe_response_bytes(4), 4);
1084        first.finish(TransportDispatchOutcome::Completed);
1085        second.finish(TransportDispatchOutcome::Completed);
1086
1087        // Completion order must not change the original wire-dispatch order.
1088        drop(second);
1089        drop(first);
1090
1091        let audit = broker.dispatch_audit();
1092        assert_eq!(audit.omitted_receipt_count(), 0);
1093        assert!(!audit.is_empty());
1094        let receipts = audit.receipts();
1095        assert_eq!(receipts.len(), 2);
1096
1097        assert_eq!(receipts[0].sequence(), 0);
1098        assert_eq!(receipts[0].action_id(), "http.control");
1099        assert_eq!(receipts[0].stage(), crate::DecisionExecutionStage::Passive);
1100        assert_eq!(
1101            receipts[0].origin(),
1102            Some(crate::DecisionActionOrigin::Bootstrap)
1103        );
1104        assert_eq!(receipts[0].request_body_bytes(), 3);
1105        assert_eq!(receipts[0].response_bytes(), 2);
1106        assert_eq!(receipts[0].outcome(), TransportDispatchOutcome::Completed);
1107
1108        assert_eq!(receipts[1].sequence(), 1);
1109        assert_eq!(receipts[1].action_id(), "http.candidate");
1110        assert_eq!(receipts[1].stage(), crate::DecisionExecutionStage::Active);
1111        assert_eq!(receipts[1].origin(), None);
1112        assert_eq!(receipts[1].request_body_bytes(), 5);
1113        assert_eq!(receipts[1].response_bytes(), 4);
1114        assert_eq!(receipts[1].outcome(), TransportDispatchOutcome::Completed);
1115    }
1116
1117    #[test]
1118    fn unclassified_dispatch_is_audited_as_cancelled() {
1119        let broker =
1120            RequestAccountingBroker::new(RuntimeBudget::default().with_max_total_requests(1));
1121        let mut lease = broker
1122            .try_begin(
1123                "http.cancelled",
1124                crate::DecisionExecutionStage::Passive,
1125                Some(crate::DecisionActionOrigin::Adaptive),
1126            )
1127            .unwrap();
1128        assert_eq!(lease.observe_response_bytes(7), 7);
1129
1130        drop(lease);
1131
1132        let audit = broker.dispatch_audit();
1133        let receipt = audit.receipts().first().unwrap();
1134        assert_eq!(audit.receipts().len(), 1);
1135        assert_eq!(receipt.action_id(), "http.cancelled");
1136        assert_eq!(receipt.response_bytes(), 7);
1137        assert_eq!(receipt.outcome(), TransportDispatchOutcome::Cancelled);
1138    }
1139
1140    #[test]
1141    fn dispatch_audit_retention_is_bounded_and_counts_omissions() {
1142        let dispatch_count = HARD_MAX_TRANSPORT_DISPATCH_RECEIPTS + 1;
1143        let broker =
1144            RequestAccountingBroker::new(RuntimeBudget::default().with_max_total_requests(
1145                u32::try_from(dispatch_count).expect("test dispatch count fits in u32"),
1146            ));
1147
1148        for index in 0..dispatch_count {
1149            let mut lease = broker
1150                .try_begin(
1151                    &format!("http.dispatch.{index}"),
1152                    crate::DecisionExecutionStage::Passive,
1153                    Some(crate::DecisionActionOrigin::Planned),
1154                )
1155                .unwrap();
1156            lease.finish(TransportDispatchOutcome::Completed);
1157        }
1158
1159        let audit = broker.dispatch_audit();
1160        assert_eq!(audit.receipts().len(), HARD_MAX_TRANSPORT_DISPATCH_RECEIPTS);
1161        assert_eq!(audit.omitted_receipt_count(), 1);
1162        assert_eq!(audit.receipts().first().unwrap().sequence(), 0);
1163        assert_eq!(
1164            audit.receipts().last().unwrap().sequence(),
1165            u64::try_from(HARD_MAX_TRANSPORT_DISPATCH_RECEIPTS - 1).unwrap()
1166        );
1167    }
1168
1169    #[test]
1170    fn denied_dispatch_does_not_create_an_audit_receipt() {
1171        let broker =
1172            RequestAccountingBroker::new(RuntimeBudget::default().with_max_total_requests(0));
1173
1174        let denied = broker
1175            .try_begin(
1176                "http.denied",
1177                crate::DecisionExecutionStage::Passive,
1178                Some(crate::DecisionActionOrigin::Planned),
1179            )
1180            .unwrap_err();
1181
1182        assert_eq!(denied.dimension(), RuntimeBudgetDimension::TotalRequests);
1183        assert_eq!(broker.snapshot(), RequestAccountingSnapshot::default());
1184        assert!(broker.dispatch_audit().is_empty());
1185    }
1186
1187    #[test]
1188    fn remaining_one_dispatch_is_acquired_atomically() {
1189        let broker =
1190            RequestAccountingBroker::new(RuntimeBudget::default().with_max_total_requests(1));
1191        let barrier = Arc::new(Barrier::new(3));
1192        let mut workers = Vec::new();
1193        for action_id in ["http.a", "http.b"] {
1194            let broker = broker.clone();
1195            let barrier = barrier.clone();
1196            workers.push(thread::spawn(move || {
1197                barrier.wait();
1198                broker
1199                    .try_begin(
1200                        action_id,
1201                        crate::DecisionExecutionStage::Passive,
1202                        Some(crate::DecisionActionOrigin::Planned),
1203                    )
1204                    .map(drop)
1205                    .map_err(|error| error.dimension())
1206            }));
1207        }
1208        barrier.wait();
1209
1210        let results: Vec<_> = workers
1211            .into_iter()
1212            .map(|worker| worker.join().unwrap())
1213            .collect();
1214        assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
1215        assert_eq!(
1216            results
1217                .iter()
1218                .filter(|result| { matches!(result, Err(RuntimeBudgetDimension::TotalRequests)) })
1219                .count(),
1220            1
1221        );
1222        assert_eq!(broker.snapshot().total_requests(), 1);
1223    }
1224
1225    #[test]
1226    fn preflight_is_advisory_but_try_begin_is_atomic() {
1227        let broker =
1228            RequestAccountingBroker::new(RuntimeBudget::default().with_max_total_requests(1));
1229        let barrier = Arc::new(Barrier::new(3));
1230        let mut workers = Vec::new();
1231        for action_id in ["http.a", "http.b"] {
1232            let broker = broker.clone();
1233            let barrier = barrier.clone();
1234            workers.push(thread::spawn(move || {
1235                broker
1236                    .preflight(action_id, crate::DecisionExecutionStage::Passive)
1237                    .unwrap();
1238                barrier.wait();
1239                broker
1240                    .try_begin(
1241                        action_id,
1242                        crate::DecisionExecutionStage::Passive,
1243                        Some(crate::DecisionActionOrigin::Planned),
1244                    )
1245                    .map(drop)
1246            }));
1247        }
1248        barrier.wait();
1249
1250        let results: Vec<_> = workers
1251            .into_iter()
1252            .map(|worker| worker.join().unwrap())
1253            .collect();
1254        assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
1255        let denied = results
1256            .iter()
1257            .find_map(|result| result.as_ref().err())
1258            .unwrap();
1259        assert_eq!(denied.dimension(), RuntimeBudgetDimension::TotalRequests);
1260        assert_eq!(broker.snapshot().total_requests(), 1);
1261    }
1262
1263    #[test]
1264    fn active_limit_is_atomic_without_blocking_passive_dispatch() {
1265        let broker = RequestAccountingBroker::new(
1266            RuntimeBudget::default()
1267                .with_max_total_requests(3)
1268                .with_max_active_verifications(1),
1269        );
1270        let barrier = Arc::new(Barrier::new(3));
1271        let workers: Vec<_> = ["verify.a", "verify.b"]
1272            .into_iter()
1273            .map(|action_id| {
1274                let broker = broker.clone();
1275                let barrier = barrier.clone();
1276                thread::spawn(move || {
1277                    barrier.wait();
1278                    broker
1279                        .try_begin(action_id, crate::DecisionExecutionStage::Active, None)
1280                        .map(drop)
1281                })
1282            })
1283            .collect();
1284        barrier.wait();
1285
1286        let results: Vec<_> = workers
1287            .into_iter()
1288            .map(|worker| worker.join().unwrap())
1289            .collect();
1290        assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
1291        let denied = results
1292            .iter()
1293            .find_map(|result| result.as_ref().err())
1294            .unwrap();
1295        assert_eq!(
1296            denied.dimension(),
1297            RuntimeBudgetDimension::ActiveVerifications
1298        );
1299        drop(
1300            broker
1301                .try_begin(
1302                    "http.passive",
1303                    crate::DecisionExecutionStage::Passive,
1304                    Some(crate::DecisionActionOrigin::Planned),
1305                )
1306                .unwrap(),
1307        );
1308        let snapshot = broker.snapshot();
1309        assert_eq!(snapshot.total_requests(), 2);
1310        assert_eq!(snapshot.active_verifications(), 1);
1311        assert_eq!(snapshot.passive_requests(), 1);
1312    }
1313
1314    #[test]
1315    fn response_byte_observations_bound_retention_and_survive_lease_drop() {
1316        let broker = RequestAccountingBroker::new(
1317            RuntimeBudget::default()
1318                .with_max_total_requests(2)
1319                .with_max_response_bytes(5),
1320        );
1321        let mut lease = broker
1322            .try_begin(
1323                "http.bootstrap",
1324                crate::DecisionExecutionStage::Passive,
1325                Some(crate::DecisionActionOrigin::Bootstrap),
1326            )
1327            .unwrap();
1328
1329        assert_eq!(lease.observe_response_bytes(3), 3);
1330        assert_eq!(lease.remaining_response_bytes(), 2);
1331        assert_eq!(lease.observe_response_bytes(4), 2);
1332        assert_eq!(lease.observe_response_bytes(1), 0);
1333        drop(lease);
1334
1335        assert_eq!(broker.snapshot().response_bytes(), 8);
1336        assert_eq!(broker.dispatch_audit().receipts()[0].response_bytes(), 8);
1337        let error = broker
1338            .preflight("http.next", crate::DecisionExecutionStage::Passive)
1339            .unwrap_err();
1340        assert_eq!(error.dimension(), RuntimeBudgetDimension::ResponseBytes);
1341    }
1342
1343    #[test]
1344    fn request_body_bytes_are_checked_and_charged_atomically_before_dispatch() {
1345        let broker = RequestAccountingBroker::new(
1346            RuntimeBudget::default()
1347                .with_max_total_requests(2)
1348                .with_max_request_body_bytes(5),
1349        );
1350        drop(
1351            broker
1352                .try_begin_with_request_body_bytes(
1353                    "http.control",
1354                    crate::DecisionExecutionStage::Passive,
1355                    Some(crate::DecisionActionOrigin::Planned),
1356                    3,
1357                )
1358                .unwrap(),
1359        );
1360
1361        let denied = broker
1362            .try_begin_with_request_body_bytes(
1363                "http.candidate",
1364                crate::DecisionExecutionStage::Active,
1365                None,
1366                3,
1367            )
1368            .unwrap_err();
1369        assert_eq!(denied.dimension(), RuntimeBudgetDimension::RequestBodyBytes);
1370        assert_eq!(denied.limit(), 5);
1371        assert_eq!(denied.observed(), 6);
1372
1373        let snapshot = broker.snapshot();
1374        assert_eq!(snapshot.total_requests(), 1);
1375        assert_eq!(snapshot.request_body_bytes(), 3);
1376        assert_eq!(snapshot.active_verifications(), 0);
1377    }
1378
1379    #[test]
1380    fn request_body_accounting_overflow_fails_closed() {
1381        let broker = RequestAccountingBroker::new(
1382            RuntimeBudget::default()
1383                .with_max_total_requests(2)
1384                .with_max_request_body_bytes(u64::MAX),
1385        );
1386        drop(
1387            broker
1388                .try_begin_with_request_body_bytes(
1389                    "http.max-body",
1390                    crate::DecisionExecutionStage::Passive,
1391                    None,
1392                    u64::MAX,
1393                )
1394                .unwrap(),
1395        );
1396
1397        let error = broker
1398            .try_begin_with_request_body_bytes(
1399                "http.overflow",
1400                crate::DecisionExecutionStage::Passive,
1401                None,
1402                1,
1403            )
1404            .unwrap_err();
1405        assert_eq!(error.dimension(), RuntimeBudgetDimension::RequestBodyBytes);
1406        assert_eq!(error.limit(), u64::MAX);
1407        assert_eq!(error.observed(), u64::MAX);
1408        assert_eq!(broker.snapshot().total_requests(), 1);
1409        assert_eq!(broker.snapshot().request_body_bytes(), u64::MAX);
1410    }
1411
1412    #[test]
1413    fn concurrent_request_bodies_cannot_exceed_the_session_limit() {
1414        let broker = RequestAccountingBroker::new(
1415            RuntimeBudget::default()
1416                .with_max_total_requests(2)
1417                .with_max_request_body_bytes(5),
1418        );
1419        let barrier = Arc::new(Barrier::new(3));
1420        let workers: Vec<_> = ["http.a", "http.b"]
1421            .into_iter()
1422            .map(|action_id| {
1423                let broker = broker.clone();
1424                let barrier = Arc::clone(&barrier);
1425                thread::spawn(move || {
1426                    barrier.wait();
1427                    broker
1428                        .try_begin_with_request_body_bytes(
1429                            action_id,
1430                            crate::DecisionExecutionStage::Passive,
1431                            Some(crate::DecisionActionOrigin::Planned),
1432                            4,
1433                        )
1434                        .map(drop)
1435                })
1436            })
1437            .collect();
1438        barrier.wait();
1439
1440        let results: Vec<_> = workers
1441            .into_iter()
1442            .map(|worker| worker.join().unwrap())
1443            .collect();
1444        assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
1445        let denied = results
1446            .iter()
1447            .find_map(|result| result.as_ref().err())
1448            .unwrap();
1449        assert_eq!(denied.dimension(), RuntimeBudgetDimension::RequestBodyBytes);
1450        assert_eq!(broker.snapshot().total_requests(), 1);
1451        assert_eq!(broker.snapshot().request_body_bytes(), 4);
1452    }
1453
1454    #[tokio::test]
1455    async fn response_read_gate_limits_concurrent_collectors_to_one_crossing_chunk() {
1456        let broker = RequestAccountingBroker::new(
1457            RuntimeBudget::default()
1458                .with_max_total_requests(3)
1459                .with_max_response_bytes(5),
1460        );
1461        let leases = ["http.a", "http.b", "http.c"].map(|action_id| {
1462            broker
1463                .try_begin(
1464                    action_id,
1465                    crate::DecisionExecutionStage::Passive,
1466                    Some(crate::DecisionActionOrigin::Planned),
1467                )
1468                .unwrap()
1469        });
1470        async fn read_one(mut lease: RequestAccountingLease) -> u64 {
1471            let _guard = lease.acquire_response_read().await;
1472            if lease.remaining_response_bytes() == 0 {
1473                return 0;
1474            }
1475            lease.observe_response_bytes(4)
1476        }
1477        let [first, second, third] = leases;
1478        let (first, second, third) =
1479            tokio::join!(read_one(first), read_one(second), read_one(third));
1480
1481        let retained = first + second + third;
1482        assert_eq!(retained, 5);
1483        assert_eq!(broker.snapshot().response_bytes(), 8);
1484    }
1485
1486    #[test]
1487    fn usage_sync_preserves_wire_shape_and_separates_action_attempts() {
1488        let broker = RequestAccountingBroker::new(
1489            RuntimeBudget::default()
1490                .with_max_total_requests(5)
1491                .with_max_active_verifications(1),
1492        );
1493        for origin in [
1494            crate::DecisionActionOrigin::Bootstrap,
1495            crate::DecisionActionOrigin::Planned,
1496            crate::DecisionActionOrigin::Adaptive,
1497            crate::DecisionActionOrigin::Retry,
1498        ] {
1499            drop(
1500                broker
1501                    .try_begin(
1502                        "http.probe",
1503                        crate::DecisionExecutionStage::Passive,
1504                        Some(origin),
1505                    )
1506                    .unwrap(),
1507            );
1508        }
1509        drop(
1510            broker
1511                .try_begin("http.probe", crate::DecisionExecutionStage::Active, None)
1512                .unwrap(),
1513        );
1514
1515        let mut usage = RuntimeUsage::default();
1516        usage.reserve_action_attempt("http.probe");
1517        usage.reserve_action_attempt("http.probe");
1518        usage.sync_request_accounting(broker.snapshot());
1519
1520        assert_eq!(usage.total_requests(), 5);
1521        assert_eq!(usage.passive_requests(), 4);
1522        assert_eq!(usage.active_verifications(), 1);
1523        assert_eq!(usage.bootstrap_requests(), 1);
1524        assert_eq!(usage.planned_requests(), 1);
1525        assert_eq!(usage.adaptive_requests(), 1);
1526        assert_eq!(usage.retry_requests(), 1);
1527        assert_eq!(usage.request_body_bytes(), 0);
1528        assert_eq!(usage.same_action_attempts("http.probe"), 2);
1529        assert_eq!(usage.total_action_attempts(), 2);
1530        assert_eq!(
1531            serde_json::to_value(&usage).unwrap(),
1532            serde_json::json!({
1533                "total_requests": 5,
1534                "passive_requests": 4,
1535                "active_verifications": 1,
1536                "bootstrap_requests": 1,
1537                "planned_requests": 1,
1538                "adaptive_requests": 1,
1539                "retry_requests": 1,
1540                "request_body_bytes": 0,
1541                "response_bytes": 0,
1542                "completed_execution_turns": 0,
1543                "consecutive_no_progress_turns": 0,
1544                "same_action_attempts": {"http.probe": 2},
1545                "elapsed_ms": 0
1546            })
1547        );
1548    }
1549
1550    #[test]
1551    fn stale_snapshot_cannot_regress_runtime_usage() {
1552        let old = RequestAccountingSnapshot {
1553            total_requests: 1,
1554            passive_requests: 1,
1555            bootstrap_requests: 1,
1556            ..RequestAccountingSnapshot::default()
1557        };
1558        let new = RequestAccountingSnapshot {
1559            total_requests: 2,
1560            passive_requests: 1,
1561            active_verifications: 1,
1562            bootstrap_requests: 1,
1563            request_body_bytes: 4,
1564            response_bytes: 4,
1565            ..RequestAccountingSnapshot::default()
1566        };
1567        let mut usage = RuntimeUsage::default();
1568
1569        usage.sync_request_accounting(new);
1570        usage.sync_request_accounting(old);
1571
1572        assert_eq!(usage.total_requests(), 2);
1573        assert_eq!(usage.passive_requests(), 1);
1574        assert_eq!(usage.active_verifications(), 1);
1575        assert_eq!(usage.bootstrap_requests(), 1);
1576        assert_eq!(usage.request_body_bytes(), 4);
1577        assert_eq!(usage.response_bytes(), 4);
1578    }
1579}