runcycles 0.3.1

Runtime authority for AI agents in Rust — hard limits on agent spend, risky tool actions, and audit gaps. Tokio-native client for the Cycles protocol (reserve-commit lifecycle, RAII guards).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
//! Protocol enumerations.
//!
//! All enums are `#[non_exhaustive]` for forward compatibility with future
//! protocol versions. Enums that appear in server responses use `#[serde(other)]`
//! on an `Unknown` variant so deserialization never fails on new values.

use serde::{Deserialize, Serialize};

/// Budget decision returned by the server.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[non_exhaustive]
pub enum Decision {
    /// Full budget available; proceed without constraints.
    Allow,
    /// Budget available with soft constraints (see [`Caps`](super::Caps)).
    AllowWithCaps,
    /// Insufficient budget; request denied.
    Deny,
    /// An unrecognized decision value from a newer protocol version.
    #[serde(other)]
    Unknown,
}

impl Decision {
    /// Returns `true` if the decision permits the operation.
    pub fn is_allowed(self) -> bool {
        matches!(self, Self::Allow | Self::AllowWithCaps)
    }

    /// Returns `true` if the decision is `Deny`.
    pub fn is_denied(self) -> bool {
        matches!(self, Self::Deny)
    }
}

/// Unit of measurement for budget amounts.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[non_exhaustive]
pub enum Unit {
    /// US dollar microcents (10^-6 cents); exact accounting.
    UsdMicrocents,
    /// Integer token counts.
    Tokens,
    /// Generic integer credits.
    Credits,
    /// Risk-scoring points.
    RiskPoints,
    /// An unrecognized unit from a newer protocol version.
    #[serde(other)]
    Unknown,
}

/// Policy for handling overage when committing actual spend.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[non_exhaustive]
pub enum CommitOveragePolicy {
    /// Reject if budget would be exceeded.
    Reject,
    /// Allow if budget is available (but not overdraft).
    AllowIfAvailable,
    /// Allow even if it creates debt (overdraft).
    AllowWithOverdraft,
}

/// Status of a reservation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[non_exhaustive]
pub enum ReservationStatus {
    /// Reservation is active and holds budget.
    Active,
    /// Reservation has been committed (actual spend recorded).
    Committed,
    /// Reservation has been released (budget returned).
    Released,
    /// Reservation has expired (TTL elapsed).
    Expired,
    /// An unrecognized status from a newer protocol version.
    #[serde(other)]
    Unknown,
}

/// Status returned after a successful commit.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[non_exhaustive]
pub enum CommitStatus {
    /// The reservation was committed successfully.
    Committed,
    /// The reservation expired before the commit landed, but the spend was
    /// recorded via the event fallback (`POST /v1/events`).
    ///
    /// **Client-side status** — never sent by the server. Set by
    /// [`ReservationGuard::commit`](crate::guard::ReservationGuard::commit)
    /// when a `RESERVATION_EXPIRED` commit is recovered as a post-hoc
    /// direct-debit event; see
    /// [`CommitResponse::recovered_via_event`](super::response::CommitResponse::recovered_via_event)
    /// for the recorded event's ID.
    ///
    /// Because it is client-synthesized only, the wire string
    /// `"RECOVERED_VIA_EVENT"` deliberately does **not** deserialize into
    /// this variant (it maps to [`Unknown`](Self::Unknown)): a
    /// non-conformant server echoing it must not be able to fabricate a
    /// recovery and violate the `Some`-iff-`RecoveredViaEvent` invariant on
    /// [`CommitResponse::recovered_via_event`](super::response::CommitResponse::recovered_via_event).
    RecoveredViaEvent,
    /// An unrecognized status from a newer protocol version.
    Unknown,
}

// Manual Deserialize (Serialize stays derived): only `"COMMITTED"` maps to a
// typed variant. `"RECOVERED_VIA_EVENT"` is client-synthesized only — a
// server sending it is non-conformant — so it falls through to `Unknown`
// like any other unrecognized value.
impl<'de> Deserialize<'de> for CommitStatus {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        let value = String::deserialize(deserializer)?;
        Ok(match value.as_str() {
            "COMMITTED" => Self::Committed,
            _ => Self::Unknown,
        })
    }
}

/// Status returned after a successful release.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[non_exhaustive]
pub enum ReleaseStatus {
    /// The reservation was released successfully.
    Released,
    /// An unrecognized status from a newer protocol version.
    #[serde(other)]
    Unknown,
}

/// Status returned after a successful TTL extension.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[non_exhaustive]
pub enum ExtendStatus {
    /// The reservation is still active with extended TTL.
    Active,
    /// An unrecognized status from a newer protocol version.
    #[serde(other)]
    Unknown,
}

/// Status returned after a successful event (direct debit).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[non_exhaustive]
pub enum EventStatus {
    /// The event was applied successfully.
    Applied,
    /// An unrecognized status from a newer protocol version.
    #[serde(other)]
    Unknown,
}

/// Error codes returned by the Cycles server.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[non_exhaustive]
pub enum ErrorCode {
    /// The request was malformed or invalid.
    InvalidRequest,
    /// Authentication failed.
    Unauthorized,
    /// The authenticated principal lacks permission.
    Forbidden,
    /// The requested resource was not found.
    NotFound,
    /// Budget is insufficient for the requested operation.
    BudgetExceeded,
    /// The budget scope is frozen.
    BudgetFrozen,
    /// The budget scope is closed.
    BudgetClosed,
    /// The reservation has expired (TTL elapsed).
    ReservationExpired,
    /// The reservation has already been committed or released.
    ReservationFinalized,
    /// Idempotency key was reused with different parameters.
    IdempotencyMismatch,
    /// The unit in the commit does not match the reservation.
    UnitMismatch,
    /// The overdraft limit for the scope has been exceeded.
    OverdraftLimitExceeded,
    /// Outstanding debt prevents the operation.
    DebtOutstanding,
    /// Maximum number of TTL extensions reached.
    MaxExtensionsExceeded,
    /// The request was rate-limited (runtime spec v0.1.25.12).
    ///
    /// Returned with HTTP 429 (server-side throttling, e.g. on the public
    /// evidence/JWKS endpoints) together with the `Retry-After` and
    /// `X-RateLimit-Reset` headers. Transient — retry after the indicated
    /// delay.
    LimitExceeded,
    /// The owning tenant is closed (runtime spec v0.1.25.13).
    ///
    /// Returned with HTTP 409 on reservation create/commit/release/extend
    /// when the owning tenant's status is CLOSED (mirrors governance spec
    /// Rule 2). Not retryable — the tenant must be reopened
    /// administratively.
    TenantClosed,
    /// An internal server error occurred.
    InternalError,
    /// An unknown error code from a newer protocol version.
    #[serde(other)]
    Unknown,
}

impl ErrorCode {
    /// Returns `true` if the error is retryable.
    ///
    /// `LimitExceeded` is HTTP 429 rate limiting (runtime spec v0.1.25.12):
    /// transient by definition — the spec instructs clients to retry after
    /// the indicated `Retry-After` delay.
    pub fn is_retryable(self) -> bool {
        matches!(
            self,
            Self::InternalError | Self::Unknown | Self::LimitExceeded
        )
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn decision_helpers() {
        assert!(Decision::Allow.is_allowed());
        assert!(Decision::AllowWithCaps.is_allowed());
        assert!(!Decision::Deny.is_allowed());
        assert!(!Decision::Unknown.is_allowed());
        assert!(Decision::Deny.is_denied());
        assert!(!Decision::Allow.is_denied());
        assert!(!Decision::Unknown.is_denied());
    }

    #[test]
    fn serde_roundtrip_decision() {
        let json = serde_json::to_string(&Decision::AllowWithCaps).unwrap();
        assert_eq!(json, "\"ALLOW_WITH_CAPS\"");
        let d: Decision = serde_json::from_str(&json).unwrap();
        assert_eq!(d, Decision::AllowWithCaps);
    }

    #[test]
    fn serde_unknown_decision_fallback() {
        let d: Decision = serde_json::from_str("\"ALLOW_WITH_WARNINGS\"").unwrap();
        assert_eq!(d, Decision::Unknown);
    }

    #[test]
    fn serde_roundtrip_unit() {
        let json = serde_json::to_string(&Unit::UsdMicrocents).unwrap();
        assert_eq!(json, "\"USD_MICROCENTS\"");
        let u: Unit = serde_json::from_str(&json).unwrap();
        assert_eq!(u, Unit::UsdMicrocents);
    }

    #[test]
    fn serde_unknown_unit_fallback() {
        let u: Unit = serde_json::from_str("\"ENERGY_JOULES\"").unwrap();
        assert_eq!(u, Unit::Unknown);
    }

    #[test]
    fn serde_roundtrip_all_units() {
        for (variant, expected) in [
            (Unit::UsdMicrocents, "\"USD_MICROCENTS\""),
            (Unit::Tokens, "\"TOKENS\""),
            (Unit::Credits, "\"CREDITS\""),
            (Unit::RiskPoints, "\"RISK_POINTS\""),
        ] {
            let json = serde_json::to_string(&variant).unwrap();
            assert_eq!(json, expected);
            let round: Unit = serde_json::from_str(&json).unwrap();
            assert_eq!(round, variant);
        }
    }

    #[test]
    fn serde_roundtrip_error_code() {
        let json = serde_json::to_string(&ErrorCode::BudgetExceeded).unwrap();
        assert_eq!(json, "\"BUDGET_EXCEEDED\"");
        let ec: ErrorCode = serde_json::from_str(&json).unwrap();
        assert_eq!(ec, ErrorCode::BudgetExceeded);
    }

    #[test]
    fn serde_roundtrip_all_error_codes() {
        let codes = [
            (ErrorCode::InvalidRequest, "\"INVALID_REQUEST\""),
            (ErrorCode::Unauthorized, "\"UNAUTHORIZED\""),
            (ErrorCode::Forbidden, "\"FORBIDDEN\""),
            (ErrorCode::NotFound, "\"NOT_FOUND\""),
            (ErrorCode::BudgetExceeded, "\"BUDGET_EXCEEDED\""),
            (ErrorCode::BudgetFrozen, "\"BUDGET_FROZEN\""),
            (ErrorCode::BudgetClosed, "\"BUDGET_CLOSED\""),
            (ErrorCode::ReservationExpired, "\"RESERVATION_EXPIRED\""),
            (ErrorCode::ReservationFinalized, "\"RESERVATION_FINALIZED\""),
            (ErrorCode::IdempotencyMismatch, "\"IDEMPOTENCY_MISMATCH\""),
            (ErrorCode::UnitMismatch, "\"UNIT_MISMATCH\""),
            (
                ErrorCode::OverdraftLimitExceeded,
                "\"OVERDRAFT_LIMIT_EXCEEDED\"",
            ),
            (ErrorCode::DebtOutstanding, "\"DEBT_OUTSTANDING\""),
            (
                ErrorCode::MaxExtensionsExceeded,
                "\"MAX_EXTENSIONS_EXCEEDED\"",
            ),
            (ErrorCode::LimitExceeded, "\"LIMIT_EXCEEDED\""),
            (ErrorCode::TenantClosed, "\"TENANT_CLOSED\""),
            (ErrorCode::InternalError, "\"INTERNAL_ERROR\""),
        ];
        for (variant, expected) in codes {
            let json = serde_json::to_string(&variant).unwrap();
            assert_eq!(json, expected, "failed for {variant:?}");
            let round: ErrorCode = serde_json::from_str(&json).unwrap();
            assert_eq!(round, variant);
        }
    }

    #[test]
    fn serde_unknown_error_code_fallback() {
        let ec: ErrorCode = serde_json::from_str("\"RATE_LIMITED\"").unwrap();
        assert_eq!(ec, ErrorCode::Unknown);
    }

    #[test]
    fn error_code_retryable() {
        assert!(ErrorCode::InternalError.is_retryable());
        assert!(ErrorCode::Unknown.is_retryable());
        // HTTP 429 rate limiting (runtime spec v0.1.25.12) is transient.
        assert!(ErrorCode::LimitExceeded.is_retryable());
        assert!(!ErrorCode::BudgetExceeded.is_retryable());
        assert!(!ErrorCode::TenantClosed.is_retryable());
        assert!(!ErrorCode::Forbidden.is_retryable());
        assert!(!ErrorCode::ReservationExpired.is_retryable());
    }

    #[test]
    fn serde_roundtrip_commit_overage_policy() {
        let policies = [
            (CommitOveragePolicy::Reject, "\"REJECT\""),
            (
                CommitOveragePolicy::AllowIfAvailable,
                "\"ALLOW_IF_AVAILABLE\"",
            ),
            (
                CommitOveragePolicy::AllowWithOverdraft,
                "\"ALLOW_WITH_OVERDRAFT\"",
            ),
        ];
        for (variant, expected) in policies {
            let json = serde_json::to_string(&variant).unwrap();
            assert_eq!(json, expected);
            let round: CommitOveragePolicy = serde_json::from_str(&json).unwrap();
            assert_eq!(round, variant);
        }
    }

    #[test]
    fn serde_roundtrip_reservation_status() {
        let statuses = [
            (ReservationStatus::Active, "\"ACTIVE\""),
            (ReservationStatus::Committed, "\"COMMITTED\""),
            (ReservationStatus::Released, "\"RELEASED\""),
            (ReservationStatus::Expired, "\"EXPIRED\""),
        ];
        for (variant, expected) in statuses {
            let json = serde_json::to_string(&variant).unwrap();
            assert_eq!(json, expected);
            let round: ReservationStatus = serde_json::from_str(&json).unwrap();
            assert_eq!(round, variant);
        }
    }

    #[test]
    fn commit_status_recovered_via_event_wire_guard() {
        // "RECOVERED_VIA_EVENT" is client-synthesized only (documented
        // "never server-sent"): a non-conformant server echoing it must not
        // be able to fabricate a recovery, so it deserializes to Unknown.
        let cs: CommitStatus = serde_json::from_str("\"RECOVERED_VIA_EVENT\"").unwrap();
        assert_eq!(cs, CommitStatus::Unknown);

        // Unrecognized future values still fall through to Unknown.
        let cs: CommitStatus = serde_json::from_str("\"PARTIALLY_COMMITTED\"").unwrap();
        assert_eq!(cs, CommitStatus::Unknown);

        // The one server-sent value still deserializes typed.
        let cs: CommitStatus = serde_json::from_str("\"COMMITTED\"").unwrap();
        assert_eq!(cs, CommitStatus::Committed);
    }

    #[test]
    fn serde_unknown_reservation_status_fallback() {
        let s: ReservationStatus = serde_json::from_str("\"PENDING\"").unwrap();
        assert_eq!(s, ReservationStatus::Unknown);
    }

    #[test]
    fn serde_roundtrip_single_value_statuses() {
        // CommitStatus
        let json = serde_json::to_string(&CommitStatus::Committed).unwrap();
        assert_eq!(json, "\"COMMITTED\"");
        let cs: CommitStatus = serde_json::from_str(&json).unwrap();
        assert_eq!(cs, CommitStatus::Committed);

        // Client-side status set by the expired-commit event fallback:
        // serializes to its wire string, but deliberately does NOT
        // deserialize back (see commit_status_recovered_via_event_wire_guard).
        let json = serde_json::to_string(&CommitStatus::RecoveredViaEvent).unwrap();
        assert_eq!(json, "\"RECOVERED_VIA_EVENT\"");

        // ReleaseStatus
        let json = serde_json::to_string(&ReleaseStatus::Released).unwrap();
        assert_eq!(json, "\"RELEASED\"");
        let rs: ReleaseStatus = serde_json::from_str(&json).unwrap();
        assert_eq!(rs, ReleaseStatus::Released);

        // ExtendStatus
        let json = serde_json::to_string(&ExtendStatus::Active).unwrap();
        assert_eq!(json, "\"ACTIVE\"");
        let es: ExtendStatus = serde_json::from_str(&json).unwrap();
        assert_eq!(es, ExtendStatus::Active);

        // EventStatus
        let json = serde_json::to_string(&EventStatus::Applied).unwrap();
        assert_eq!(json, "\"APPLIED\"");
        let evs: EventStatus = serde_json::from_str(&json).unwrap();
        assert_eq!(evs, EventStatus::Applied);
    }
}