Skip to main content

tollgate_store/
wire.rs

1//! The HTTP wire contract between `tollgate-server` and `tollgate-client`'s HTTP
2//! transport. One place, versioned by [`API_PREFIX`] on the server.
3//!
4//! Every 128-bit identifier is exactly 32 lowercase hexadecimal characters,
5//! without a `0x` prefix, in both JSON strings and URL path segments. JSON
6//! numbers are not portable for these values: common consumers round integers
7//! above 2^53. Snapshots travel whole, cost table included — the receiving
8//! instance validates them and resolves nothing again.
9
10use std::sync::Arc;
11
12use jiff::SignedDuration;
13use serde::{Deserialize, Serialize};
14
15use tollgate_core::{
16    AccountId, AccountSnapshot, AccountStatus, CapacityClass, CostUnits, FencingToken, Generation,
17    LeaseId, UsageEvent,
18};
19
20/// Current HTTP wire-contract prefix.
21///
22/// The pre-public V1 contract uses canonical textual identifiers. Keeping the
23/// prefix here makes client and server select the same contract without
24/// duplicating a magic string.
25pub const API_PREFIX: &str = "/v1";
26
27/// The widest a single [`UsageEvent`] serialises to, in bytes.
28///
29/// Measured, not estimated: every identifier at its full 32-hex width, the
30/// policy revision at its full 64-hex width, both 64-bit fields at `u64::MAX`,
31/// and an expanded negative year with nine fractional digits — the leased
32/// form, which carries a lease id and fencing token the overage form does not.
33///
34/// ```text
35/// {"request_id":"ff…ff","account_id":"ff…ff","source":{"Leased":
36///  {"lease_id":"ff…ff","fencing_token":18446744073709551615}},
37///  "units":18446744073709551615,"occurred_at":"-009999-01-02T01:59:59.999999999Z",
38///  "policy_revision":"ff…ff","key_id":"ff…ff"}
39/// ```
40///
41/// Pinned by `the_widest_usage_event_still_fits_its_declared_size`, so a field
42/// added to `UsageEvent` cannot silently push a legitimate batch past the body
43/// limit derived from this number.
44///
45/// GL-94 raised it from 268: the policy revision is fixed-width, so it costs the
46/// same 85 bytes on every event whether stated or unstated. That is the price
47/// of carrying it on the wire in its canonical spelling, and it is recorded
48/// here rather than discovered when a maximal batch starts being refused.
49/// GL-105 adds the optional key ID and includes expanded negative years and
50/// nine fractional digits in the fixture: measured maximum 410 bytes.
51pub const MAX_USAGE_EVENT_BYTES: usize = 410;
52
53/// The body limit `/v1/usage/ingest` is served with, in bytes.
54///
55/// Derived from the two constants above rather than chosen: a full batch of
56/// the widest events is `MAX_INGEST_BATCH * (MAX_USAGE_EVENT_BYTES + 1)` — the
57/// `+ 1` being each event's separating comma — plus `{"events":[]}`. That is
58/// about 1.61 MiB, and 2 MiB still leaves room to
59/// spare, so a legitimate maximal batch is never refused for want of a byte.
60/// The headroom is checked by
61/// `a_full_batch_of_the_widest_events_fits_the_declared_body_limit`, not
62/// assumed: the next field to land here may be the one that exhausts it, and
63/// this limit must then rise with it.
64///
65/// Declared rather than inherited. Without it the endpoint ran on axum's
66/// implicit 2 MiB default, which no document stated and which the server
67/// reported as malformed JSON when it bit (GL-61).
68pub const MAX_INGEST_BODY_BYTES: usize = 2 * 1024 * 1024;
69
70/// Four maximal u64 counters plus field names and framing. Includes a
71/// present attribution count; legacy missing/null values are shorter.
72/// Pinned by the maximal acknowledgement serialization witness.
73pub const MAX_INGEST_REPORT_BYTES: usize = 134;
74
75/// The body limit `PUT /v1/admin/snapshots/{principal}` is served with, in
76/// bytes.
77///
78/// A published snapshot carries its whole cost table, so its worst case grows
79/// with the number of priced classes rather than with any batch size. Measured
80/// against that: full-width weights and per-operation permissions serialize
81/// as parallel arrays at about 32 bytes per class. A hundred-thousand-class
82/// catalogue is about 3.1 MiB and fits with room for the snapshot envelope. Pinned
83/// by `the_snapshot_limit_admits_a_hundred_thousand_class_catalogue`. It is stated for the same
84/// reason the ingest limit is — an operator publishing a large catalogue
85/// should be refused by a documented number or not at all, never by an
86/// undocumented default reported as bad JSON.
87pub const MAX_SNAPSHOT_BODY_BYTES: usize = 4 * 1024 * 1024;
88
89/// One complete active credential projection. Digests are fixed 32-byte arrays;
90/// HMAC secrets and raw credentials are never part of this response.
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct KeysResponse {
93    /// The source revision the page was read at.
94    pub revision: u64,
95    /// The server-clock instant active records were selected at.
96    pub as_of: jiff::Timestamp,
97    /// Active credentials, in strictly increasing key-id order.
98    pub keys: Vec<crate::CredentialRecord>,
99    /// Cursor for the next page, or `null` when this page is the last. Required:
100    /// an omitted field is refused rather than read as terminal.
101    #[serde(deserialize_with = "crate::credentials::required_option")]
102    pub next_after: Option<tollgate_core::KeyId>,
103}
104
105/// Fixed-width identifiers/digest plus the widest timestamp and JSON framing.
106/// `wire_limits` measures a maximal record and page against these bounds.
107pub const MAX_KEY_RECORD_BYTES: usize = 216;
108/// The maximal envelope is 134 bytes; the first record needs no comma.
109pub const MAX_KEYS_BODY_BYTES: usize = crate::MAX_KEY_PAGE_LIMIT * (MAX_KEY_RECORD_BYTES + 1) + 133;
110
111/// The TTL fields shared by both lease-creating requests.
112///
113/// Positive whole seconds through `u32::MAX` retain the original
114/// `ttl_seconds` spelling. Every other positive duration carries Jiff's exact
115/// duration string in `ttl`, with `ttl_seconds: 0`: a legacy server rejects
116/// that sentinel instead of silently allocating a differently timed lease.
117/// Upgrade servers before enabling these durations on HTTP clients.
118///
119/// Deserialized fields are untrusted; [`Self::duration`] validates them before
120/// a handler invokes its allocator. [`Self::try_from`] refuses nonpositive caller
121/// input before an HTTP request is built. Neither operation applies policy's
122/// maximum TTL: that remains the allocator's decision.
123#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
124pub struct LeaseTtl {
125    ttl_seconds: u32,
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    ttl: Option<SignedDuration>,
128}
129
130impl TryFrom<SignedDuration> for LeaseTtl {
131    type Error = crate::AllocateError;
132
133    fn try_from(ttl: SignedDuration) -> Result<Self, Self::Error> {
134        if ttl <= SignedDuration::ZERO {
135            return Err(crate::AllocateError::InvalidTtl);
136        }
137        Ok(match (u32::try_from(ttl.as_secs()), ttl.subsec_nanos()) {
138            (Ok(ttl_seconds), 0) => Self {
139                ttl_seconds,
140                ttl: None,
141            },
142            _ => Self {
143                ttl_seconds: 0,
144                ttl: Some(ttl),
145            },
146        })
147    }
148}
149
150impl LeaseTtl {
151    /// The lease lifetime this request declares.
152    ///
153    /// # Errors
154    ///
155    /// [`AllocateError::InvalidTtl`](crate::AllocateError::InvalidTtl) when
156    /// `ttl_seconds` is nonzero alongside `ttl`, or the declared duration is not
157    /// strictly positive.
158    pub fn duration(self) -> Result<SignedDuration, crate::AllocateError> {
159        let ttl = match (self.ttl_seconds, self.ttl) {
160            (0, Some(ttl)) => ttl,
161            (seconds, None) => SignedDuration::from_secs(i64::from(seconds)),
162            // Two nonzero declarations have no implicit precedence.
163            _ => return Err(crate::AllocateError::InvalidTtl),
164        };
165        if ttl <= SignedDuration::ZERO {
166            return Err(crate::AllocateError::InvalidTtl);
167        }
168        Ok(ttl)
169    }
170}
171
172/// Body of `POST /v1/leases/acquire`: [`LeaseAllocator::acquire`](crate::LeaseAllocator::acquire)
173/// over HTTP.
174#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
175pub struct AcquireRequest {
176    /// The account to debit.
177    pub account_id: AccountId,
178    /// Units asked for. The grant may be smaller, per the server's [`GrantPolicy`](crate::GrantPolicy).
179    pub requested: CostUnits,
180    /// Requested lease lifetime, flattened into this object. The server clamps it to its policy's `max_ttl`.
181    #[serde(flatten)]
182    pub ttl: LeaseTtl,
183}
184
185/// A grant with its funding evidence flattened beside it. A client that
186/// predates the evidence reads the grant and ignores `funding`; a server that
187/// predates it omits `funding`, which reads as no attestation.
188pub type AcquireResponse = crate::Allocation;
189
190/// Body of `POST /v1/leases/release`: [`LeaseAllocator::release`](crate::LeaseAllocator::release)
191/// over HTTP.
192#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
193pub struct ReleaseRequest {
194    /// The lease to release.
195    pub lease_id: LeaseId,
196    /// The fencing token stored with that lease; any other value is refused as fenced.
197    pub fencing_token: FencingToken,
198    /// Units the holder did not spend, credited back to the account.
199    pub unspent: CostUnits,
200}
201
202/// A lease returned and re-granted in one server-side transaction.
203///
204/// Deliberately not an `AcquireRequest` plus a `ReleaseRequest`: the account
205/// is the one the released lease names, so there is no field for a client to
206/// disagree with the ledger about (see `LeaseAllocator::consolidate`).
207#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
208pub struct ConsolidateRequest {
209    /// The lease to return.
210    pub lease_id: LeaseId,
211    /// The fencing token stored with that lease; any other value is refused as fenced.
212    pub fencing_token: FencingToken,
213    /// Units the holder did not spend on the returned lease.
214    pub unspent: CostUnits,
215    /// Units asked for in the replacement grant.
216    pub requested: CostUnits,
217    /// The largest quote the returned lease refused (GL-131). Omitted when
218    /// zero, so a server that predates it sees the request it always did;
219    /// absent from an older client, it reads as zero: today's sizing.
220    #[serde(default, skip_serializing_if = "no_demand")]
221    pub needed: CostUnits,
222    /// Replacement lease lifetime, flattened into this object.
223    #[serde(flatten)]
224    pub ttl: LeaseTtl,
225}
226
227/// Response to `POST /v1/leases/consolidate`: the replacement grant and its
228/// funding evidence, shaped exactly as [`AcquireResponse`].
229pub type ConsolidateResponse = crate::Allocation;
230
231// Serde passes a reference; `CostUnits::is_zero` takes the value.
232#[allow(
233    clippy::trivially_copy_pass_by_ref,
234    reason = "serde's skip_serializing_if passes the field by reference"
235)]
236fn no_demand(units: &CostUnits) -> bool {
237    units.is_zero()
238}
239
240/// The owned form, which the server needs: axum's `Json<T>` extractor requires
241/// `DeserializeOwned`, so the receiving side cannot borrow from the body.
242#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct IngestRequest {
244    /// The usage batch. The route's body limit, [`MAX_INGEST_BODY_BYTES`], admits
245    /// [`MAX_INGEST_BATCH`](crate::MAX_INGEST_BATCH) of the widest events.
246    pub events: Vec<UsageEvent>,
247}
248
249/// The sending form. `UsageSink::ingest` hands the transport a `&[UsageEvent]`
250/// it does not own, and copying that batch into a `Vec` just to reach serde
251/// bought nothing — least of all on the one path where it happened repeatedly.
252/// A failing sink is retried indefinitely with backoff by design (an outage is
253/// a duration, not an event), and every attempt re-copied the same batch (GL-20).
254///
255/// This produces byte-identical JSON to [`IngestRequest`], which is the whole
256/// reason it is safe to have two types; `borrowed_and_owned_ingest_requests_serialize_identically`
257/// is what checks that rather than trusting it.
258#[derive(Debug, Serialize)]
259pub struct IngestRequestRef<'a> {
260    /// The usage batch to send.
261    pub events: &'a [UsageEvent],
262}
263
264/// Body of `POST /v1/admin/accounts`. The server creates the account with
265/// [`CapacityClass::Assured`]; change it afterwards with
266/// [`SetCapacityClassRequest`].
267#[derive(Debug, Clone, Serialize, Deserialize)]
268pub struct CreateAccountRequest {
269    /// Identifier for the new account.
270    pub account_id: AccountId,
271    /// Opening balance, deposited as a top-up.
272    pub initial_balance: CostUnits,
273    /// Administrative status at creation.
274    pub status: AccountStatus,
275}
276
277/// Body of `POST /v1/admin/accounts/{account}/deposit`.
278#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
279pub struct DepositRequest {
280    /// Units to add as a top-up. The server refuses zero.
281    pub units: CostUnits,
282}
283
284#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
285/// The one operator action for an account's administrative status (GL-51).
286///
287/// An [`AccountStatus`] rather than the `active` bool this carried before:
288/// the bool named only the ledger flag, while the request now also republishes
289/// every live snapshot of the account. Changing what an existing field means
290/// would have been the silent-semantics change the repository guidelines
291/// forbid, so the field is renamed and an old body fails loudly.
292pub struct SetStatusRequest {
293    /// The status to set.
294    pub status: AccountStatus,
295}
296
297/// The one operator action for an account's execution-capacity class (GL-99).
298///
299/// Its own request type rather than an optional field on
300/// [`SetStatusRequest`]: the two are different operator decisions about
301/// different axes, and a combined body would make "change the status" and
302/// "change the class" indistinguishable from "change the status and leave the
303/// class alone" without a nested `Option` nobody would enjoy reading.
304///
305/// The response is [`SetStatusResponse`], reused deliberately: both actions
306/// answer the same question — how many live snapshots did this move, and how
307/// many rows could not be pushed.
308#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
309pub struct SetCapacityClassRequest {
310    /// The class to set.
311    pub capacity_class: CapacityClass,
312}
313
314/// What a status change did, so an operator learns its blast radius at the
315/// moment of the call rather than from a later denial (GL-51).
316///
317/// `republished: 0` means the account had no live snapshot to change — no
318/// credentials, all of them revoked, or the change was a repeat. All three are
319/// worth seeing.
320#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
321pub struct SetStatusResponse {
322    /// Live snapshots republished with the new value; see [`StatusChange::republished`](crate::StatusChange::republished).
323    pub republished: usize,
324    /// Rows that changed durably but could not be decoded well enough to push,
325    /// so those principals converge only at their next refresh. Always zero on
326    /// an in-memory backend.
327    pub unreadable: usize,
328}
329
330/// Body of both snapshot-publication routes:
331/// `PUT /v1/admin/snapshots/{principal}` and
332/// `PUT /v1/admin/accounts/{account}/keys/{key}/snapshot`.
333#[derive(Debug, Clone, Serialize, Deserialize)]
334pub struct PublishSnapshotRequest {
335    /// The complete compiled snapshot, cost table included, which the server
336    /// validates before publishing. On the credential-bound route, an unstated
337    /// `key_id` is filled in from the path.
338    pub snapshot: Arc<AccountSnapshot>,
339}
340
341/// The catalogue of principals a control plane knows, revoked ones included
342/// (GL-48). An instance serving any customer needs this to learn the set that
343/// already exists; pushes only carry what changes after it subscribes.
344#[derive(Debug, Clone, Serialize, Deserialize)]
345pub struct PrincipalsResponse {
346    /// Every principal the control plane knows, revoked ones included.
347    pub principals: Vec<tollgate_core::Principal>,
348}
349
350/// RFC-7807-shaped error body with a stable machine `code`, mirrored back
351/// into domain errors by the HTTP transport.
352#[derive(Debug, Clone, Serialize, Deserialize)]
353pub struct Problem {
354    /// The HTTP status code of the response.
355    pub status: u16,
356    /// Stable machine-readable error code, such as `unknown-account`, which the
357    /// HTTP transport maps back to a domain error.
358    pub code: String,
359    /// Human-readable summary. Never carries backend error text (INVARIANTS.md 37).
360    pub title: String,
361    /// Optional extension used by versioned tombstone responses.
362    #[serde(default, skip_serializing_if = "Option::is_none")]
363    pub generation: Option<Generation>,
364    /// Authoritative exhaustion, present only on a confirmed allocator refusal.
365    #[serde(default, skip_serializing_if = "Option::is_none")]
366    pub balance_exhaustion: Option<tollgate_core::BalanceExhaustion>,
367    /// Authoritative remaining funding on an `insufficient-balance` refusal.
368    /// The code is unchanged, so a client that ignores this extension reads
369    /// the same unattested refusal it always did.
370    #[serde(default, skip_serializing_if = "Option::is_none")]
371    pub balance_shortfall: Option<tollgate_core::BalanceShortfall>,
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377    use jiff::Timestamp;
378    use tollgate_core::PolicyRevision;
379    use tollgate_core::RequestId;
380    use tollgate_core::UsageSource;
381
382    /// GL-131's demand field changes nothing on the wire until it is used: an
383    /// older server never sees it at zero, and an older client's request
384    /// reads as zero.
385    #[test]
386    fn consolidation_demand_is_invisible_until_used() {
387        let request = |needed| ConsolidateRequest {
388            lease_id: LeaseId(1),
389            fencing_token: FencingToken(1),
390            unspent: CostUnits(30),
391            requested: CostUnits(1_000),
392            needed: CostUnits(needed),
393            ttl: LeaseTtl::try_from(SignedDuration::from_secs(60)).unwrap(),
394        };
395        let idle = serde_json::to_value(request(0)).unwrap();
396        assert!(idle.get("needed").is_none(), "{idle}");
397        let old: ConsolidateRequest = serde_json::from_value(idle).unwrap();
398        assert_eq!(old.needed, CostUnits::ZERO);
399        let demand = serde_json::to_value(request(51)).unwrap();
400        assert_eq!(demand["needed"], 51);
401        let parsed: ConsolidateRequest = serde_json::from_value(demand).unwrap();
402        assert_eq!(parsed.needed, CostUnits(51));
403    }
404
405    fn event(seq: u128) -> UsageEvent {
406        UsageEvent::new(
407            RequestId(seq),
408            AccountId(7),
409            UsageSource::Leased {
410                lease_id: LeaseId(11),
411                fencing_token: FencingToken(3),
412            },
413            CostUnits(64),
414            Timestamp::from_second(1_755_600_000).unwrap(),
415            PolicyRevision::UNSTATED,
416            None,
417        )
418    }
419
420    /// GL-20 added a second ingest type so the transport could stop copying the
421    /// batch, and the entire argument for that being safe is that the two
422    /// produce the same bytes. Checked here rather than trusted, because it is
423    /// a claim about what leaves the process.
424    #[test]
425    fn borrowed_and_owned_ingest_requests_serialize_identically() {
426        for count in [0, 1, 256] {
427            let events: Vec<UsageEvent> = (0..count).map(event).collect();
428            let borrowed = serde_json::to_string(&IngestRequestRef { events: &events }).unwrap();
429            let owned = serde_json::to_string(&IngestRequest {
430                events: events.clone(),
431            })
432            .unwrap();
433            assert_eq!(
434                borrowed, owned,
435                "the two ingest forms disagree at {count} events, so the wire \
436                 format depends on which one the caller happened to use"
437            );
438        }
439    }
440
441    /// The client→server path in one fast test: the transport serializes the
442    /// borrowed form and the server deserializes the owned one. A field added
443    /// to one type and not the other fails here in milliseconds instead of
444    /// only in the loopback suite.
445    #[test]
446    fn a_borrowed_request_deserializes_as_the_owned_one() {
447        let events: Vec<UsageEvent> = (0..3).map(event).collect();
448        let body = serde_json::to_string(&IngestRequestRef { events: &events }).unwrap();
449        let received: IngestRequest = serde_json::from_str(&body).unwrap();
450        assert_eq!(received.events, events);
451    }
452
453    /// An empty flush is legitimate — the writer can wake with nothing queued
454    /// — and must not become a malformed body or a missing field.
455    #[test]
456    fn an_empty_batch_stays_an_empty_list() {
457        let body = serde_json::to_string(&IngestRequestRef { events: &[] }).unwrap();
458        assert_eq!(body, r#"{"events":[]}"#);
459        let received: IngestRequest = serde_json::from_str(&body).unwrap();
460        assert!(received.events.is_empty());
461    }
462
463    #[test]
464    fn high_bit_ids_are_portable_text_in_an_untyped_json_consumer() {
465        let high = (1u128 << 127) | 0x2a;
466        let mut event = event(high);
467        event.account_id = AccountId(high + 1);
468        event.source = UsageSource::Leased {
469            lease_id: LeaseId(high + 2),
470            fencing_token: FencingToken(3),
471        };
472        let events = [event];
473        let value = serde_json::to_value(IngestRequestRef { events: &events }).unwrap();
474        let event = &value["events"][0];
475        let leased = &event["source"]["Leased"];
476
477        for (field, node, expected) in [
478            ("request_id", event, high),
479            ("account_id", event, high + 1),
480            ("lease_id", leased, high + 2),
481        ] {
482            let text = node[field]
483                .as_str()
484                .unwrap_or_else(|| panic!("{field} must be JSON text, got {}", node[field]));
485            assert_eq!(text.len(), 32);
486            assert_eq!(u128::from_str_radix(text, 16).unwrap(), expected);
487        }
488
489        let received: IngestRequest = serde_json::from_value(value).unwrap();
490        assert_eq!(received.events, events);
491    }
492}
493
494/// One account as an operator reads it (GL-121).
495///
496/// **Authoritative, not an estimate**, and as of the instant it was read: every
497/// field comes from one consistent backend snapshot, so the terms agree with
498/// each other. It is not a live feed — an admission committed a millisecond
499/// later is not in it — so a caller comparing two reads is comparing two
500/// instants, which is why `as_of` is part of the answer rather than left to a
501/// header.
502///
503/// **Funding is not billing.** `balance` is what remains *spendable*, and it
504/// falls for three different reasons that must not be conflated:
505/// units going out on a lease that has not settled
506/// (`outstanding_lease_grants`), units actually consumed (`settled_usage`),
507/// and a budget period closing on an unspent allowance (`expired_allowance`).
508/// Only the second is billed. A surface reporting depletion alone would let a
509/// customer read a grant as spend.
510///
511/// All unit counts are `CostUnits` — whole units, never fractional and never a
512/// currency; converting to money is the application's job, with its own
513/// prices.
514#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
515pub struct AccountResponse {
516    /// The account read.
517    pub account_id: tollgate_core::AccountId,
518    /// When this view was read. Every other field is as of this instant.
519    pub as_of: jiff::Timestamp,
520    /// Administrative status.
521    pub status: tollgate_core::AccountStatus,
522    /// Execution-capacity class.
523    pub capacity_class: tollgate_core::CapacityClass,
524    /// Which authority created the account (#39). Defaults to `Operator` when
525    /// absent, which is what every account a server predating the field
526    /// holds.
527    #[serde(default = "operator_authority")]
528    pub origin: crate::AdminAuthority,
529    /// Which authority set the current `status`. An operator's `Suspended` is
530    /// a hold a provisioner cannot lift (#39). Defaults as `origin` does.
531    #[serde(default = "operator_authority")]
532    pub status_set_by: crate::AdminAuthority,
533    /// The periodic allowance, or `None` for "no schedule; the balance does
534    /// not expire". Not the same as an allowance of zero.
535    pub budget: Option<tollgate_core::BudgetSchedule>,
536    /// First instant of the period currently in force. Meaningful only
537    /// alongside `budget`.
538    pub period_start: jiff::Timestamp,
539    /// Still spendable. **Not** a bill, and not what has been used.
540    pub balance: tollgate_core::CostUnits,
541    /// Out on leases that have not settled: committed capacity, not yet spend,
542    /// and not yet billable.
543    pub outstanding_lease_grants: tollgate_core::CostUnits,
544    /// Consumed and billable. This is the usage number.
545    pub settled_usage: tollgate_core::CostUnits,
546    /// Funded but never spendable again, because the period that funded them
547    /// closed.
548    pub expired_allowance: tollgate_core::CostUnits,
549    /// Granted units that settled without being accounted for by usage —
550    /// reported rather than absorbed, because silence would make loss look
551    /// like unspent capacity.
552    pub settlement_loss: tollgate_core::CostUnits,
553    /// Everything ever deposited, and unfunded units admitted under
554    /// `Elastic`. Together these are the left side of the funding equation
555    /// whose right side is the four figures above.
556    pub deposited: tollgate_core::CostUnits,
557    /// Unfunded units admitted under `Elastic` enforcement; see [`Conservation::overage_recorded`](crate::Conservation::overage_recorded).
558    pub overage_recorded: tollgate_core::CostUnits,
559}
560
561/// Set or clear an account's periodic allowance (GL-121).
562///
563/// `budget: null` clears the schedule, which is a different request from one
564/// with an allowance of zero: the first means "this balance does not expire",
565/// the second means "this account is funded nothing each period". The field is
566/// required rather than defaulted so neither can be reached by omission.
567#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
568#[serde(deny_unknown_fields)]
569pub struct SetBudgetRequest {
570    /// The schedule to set, or `null` to clear it. Required: an omitted field is refused.
571    #[serde(deserialize_with = "required_budget")]
572    pub budget: Option<tollgate_core::BudgetSchedule>,
573}
574
575// A custom field deserializer makes omission an error while still accepting
576// explicit JSON null. Plain Option deserialization also accepts missing fields.
577fn required_budget<'de, D>(
578    deserializer: D,
579) -> Result<Option<tollgate_core::BudgetSchedule>, D::Error>
580where
581    D: serde::Deserializer<'de>,
582{
583    Option::deserialize(deserializer)
584}
585
586/// What a budget change committed.
587///
588/// Both states, because "set to 500" is not the useful answer on its own — an
589/// operator needs to know whether that introduced a schedule, replaced a
590/// different one, or changed nothing. Equal values mean the call was a no-op.
591#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
592pub struct SetBudgetResponse {
593    /// The schedule this call replaced, or `null` when there was none.
594    pub previous: Option<tollgate_core::BudgetSchedule>,
595    /// The schedule now in force, which is the one requested.
596    pub current: Option<tollgate_core::BudgetSchedule>,
597}
598
599/// Issue one credential for an account (GL-121).
600///
601/// The caller chooses `key_id`, and that choice is the retry contract: a
602/// request that is lost after the credential is stored can be resent with the
603/// same id and will be refused as a duplicate rather than minting a second
604/// credential. Choose an unguessable one (a v4 UUID) and keep it until the
605/// call is acknowledged.
606///
607/// `max_active_keys` is the caller's own policy, enforced here atomically
608/// against concurrent issuers. It is supplied per request because what counts
609/// as a reasonable number of credentials belongs to the application's plan.
610#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
611#[serde(deny_unknown_fields)]
612pub struct IssueKeyRequest {
613    /// Caller-chosen identifier for the new credential. Resending it after a lost
614    /// response is refused as a duplicate.
615    pub key_id: tollgate_core::KeyId,
616    /// The most live credentials the account may hold. Issuance is refused when
617    /// the account already holds this many; the count and the insert are atomic.
618    pub max_active_keys: std::num::NonZeroUsize,
619    /// When the credential stops being valid of its own accord. `None` means
620    /// it lapses only on revocation.
621    #[serde(default)]
622    pub not_after: Option<jiff::Timestamp>,
623}
624
625/// A newly issued credential — **the only time its secret is ever returned**.
626///
627/// The secret is not stored anywhere in recoverable form: what persists is an
628/// HMAC of it. A caller that loses this response cannot get the secret back by
629/// any means, and resending the request answers `409` rather than reissuing.
630/// The recovery is to revoke the credential and issue a new one under a new
631/// `key_id`.
632#[derive(Debug, Clone, Serialize, Deserialize)]
633pub struct IssuedKeyResponse {
634    /// The issued credential's identifier.
635    pub key_id: tollgate_core::KeyId,
636    /// The bearer secret, hex-encoded. Disclosed once.
637    pub secret: String,
638    /// The credential's expiry, as requested, or `null` for none.
639    pub not_after: Option<jiff::Timestamp>,
640}
641
642/// One credential in an account listing. Never carries the secret, the
643/// verifier digest, or the principal — the principal is the leading 128 bits
644/// of the digest, so exposing it would leak half of it.
645#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
646pub struct AccountKeyResponse {
647    /// The credential's non-secret identifier.
648    pub key_id: tollgate_core::KeyId,
649    /// When the credential stops being valid of its own accord, or `null` if never.
650    pub not_after: Option<jiff::Timestamp>,
651    /// When the credential was revoked, or `null` if it has not been.
652    pub revoked_at: Option<jiff::Timestamp>,
653    /// Whether this credential can still authenticate as of `as_of` in the
654    /// enclosing page. Derived, so a caller need not re-implement the rule.
655    pub live: bool,
656}
657
658/// One page of an account's credentials.
659#[derive(Debug, Clone, Serialize, Deserialize)]
660pub struct AccountKeysResponse {
661    /// The server-clock instant each `live` flag is evaluated at.
662    pub as_of: jiff::Timestamp,
663    /// Credentials in increasing key-id order, revoked and expired ones included.
664    pub keys: Vec<AccountKeyResponse>,
665    /// Cursor for the next page, or `None` when this page is the last.
666    pub next_after: Option<tollgate_core::KeyId>,
667}
668
669/// What a revocation did. `retired: false` means the credential was already
670/// revoked — reported rather than treated as an error, because the caller's
671/// intent is satisfied either way, but the distinction matters in an audit.
672#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
673pub struct RevokeKeyResponse {
674    /// The credential named in the request.
675    pub key_id: tollgate_core::KeyId,
676    /// `true` if this call retired the credential, `false` if it was already revoked.
677    pub retired: bool,
678}
679
680fn operator_authority() -> crate::AdminAuthority {
681    crate::AdminAuthority::Operator
682}