返回 CodeWhale
computer_meter.rs
根目录 / crates / tui / src / computer_meter.rs
1 //! Provider-accepted Computer active-second receipts.
2 //!
3 //! **Scope: this module is not billing input.** The control plane in
4 //! codewhale-apps is the billing authority. It writes sandbox state intervals
5 //! itself and derives receipts server-side from them; it never accepts a
6 //! receipt from a client, correctly, because the CLI runs on the customer's
7 //! machine. Anything issued here is a local display or self-check.
8 //!
9 //! "Codewhale is the billing authority" below is a statement about *what is
10 //! billable*, not about which service decides: entitlement moves for
11 //! provider-accepted **active** seconds rather than Daytona's provisioned wall
12 //! clock. An audit read it as core-versus-control-plane and carried that
13 //! misreading into codewhale-apps, where it shaped a PR before it was caught.
14 //!
15 //! Codewhale is the billing authority over the *measure*. Daytona (or a future
16 //! adapter) supplies infrastructure only. Entitlement moves solely for
17 //! provider-accepted *active* seconds, per second, multiplied by the selected
18 //! profile's 1x/2x/4x rate.
19 //!
20 //! **Unwired as of 2026-09-01.** The only consumer, `cloud_dispatch::
21 //! meter_cloud_job`, is itself called only from `#[cfg(test)] mod tests`
22 //! (cloud_dispatch.rs:1210 and :1227, inside the module opening at :917). No
23 //! `ComputerMeterReceipt` is transmitted anywhere. This is a complete
24 //! implementation that was never connected; it must be deliberately wired as a
25 //! local self-check or deleted, because a dead module that reads as
26 //! authoritative is exactly how the drift above happened.
27 //!
28 //! Wall-clock-if-idle, requested, queued, rejected, stopped, suspended,
29 //! archived, failed-before-acceptance, and teardown-tail time cannot mint a
30 //! receipt. Provider-observed CPU/RAM/disk must equal the admitted profile.
31 //! Corrections are append-only and preserve the original receipt.
32
33 use chrono::{DateTime, SecondsFormat, Utc};
34 use serde::{Deserialize, Serialize};
35 use thiserror::Error;
36
37 use crate::hashing::sha256_hex;
38
39 /// Pinned v3 meter revision. Bump only with a catalog owner change.
40 pub const COMPUTER_METER_REVISION: &str = "computer-meter-v3.20260831";
41 /// Pinned v3 profile catalog revision.
42 pub const COMPUTER_CATALOG_REVISION: &str = "computer-profiles-v3.20260831";
43 /// Admission record schema.
44 pub const COMPUTER_ADMISSION_SCHEMA: &str = "codewhale.computer-admission/v1";
45 /// Meter receipt schema.
46 pub const COMPUTER_METER_RECEIPT_SCHEMA: &str = "codewhale.computer-meter-receipt/v1";
47 /// Receipt kind for one accepted active interval.
48 pub const COMPUTER_METER_RECEIPT_KIND: &str = "computer.meter.active_seconds";
49
50 const ADMISSION_DIGEST_NS: &str = "codewhale/computer-admission/v1";
51 const RECEIPT_DIGEST_NS: &str = "codewhale/computer-meter-receipt/v1";
52 const MAX_REF_CHARS: usize = 240;
53
54 /// Ratified v3 launch profiles. Historic `standard`/`large`/`xl` decode only.
55 pub const COMPUTER_PROFILES: [ComputerProfile; 3] = [
56 ComputerProfile {
57 id: ComputerProfileId::Standard8,
58 label: "8 GB",
59 cpu: 2,
60 memory_gib: 8,
61 disk_gib: 8,
62 multiplier: 1,
63 },
64 ComputerProfile {
65 id: ComputerProfileId::Standard16,
66 label: "16 GB",
67 cpu: 4,
68 memory_gib: 16,
69 disk_gib: 16,
70 multiplier: 2,
71 },
72 ComputerProfile {
73 id: ComputerProfileId::Standard32,
74 label: "32 GB",
75 cpu: 8,
76 memory_gib: 32,
77 disk_gib: 32,
78 multiplier: 4,
79 },
80 ];
81
82 /// Selectable, quotable, creatable v3 Computer profile.
83 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
84 pub enum ComputerProfileId {
85 /// 2 vCPU / 8 GiB RAM / 8 GiB disk at 1x.
86 #[serde(rename = "standard-8")]
87 Standard8,
88 /// 4 vCPU / 16 GiB RAM / 16 GiB disk at 2x.
89 #[serde(rename = "standard-16")]
90 Standard16,
91 /// 8 vCPU / 32 GiB RAM / 32 GiB disk at 4x.
92 #[serde(rename = "standard-32")]
93 Standard32,
94 }
95
96 impl ComputerProfileId {
97 /// Stable catalog id.
98 #[must_use]
99 pub fn as_str(self) -> &'static str {
100 match self {
101 Self::Standard8 => "standard-8",
102 Self::Standard16 => "standard-16",
103 Self::Standard32 => "standard-32",
104 }
105 }
106 }
107
108 /// Fixed resource envelope and allowance multiplier.
109 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
110 #[serde(rename_all = "camelCase")]
111 pub struct ComputerProfile {
112 /// Catalog id.
113 pub id: ComputerProfileId,
114 /// Customer label.
115 pub label: &'static str,
116 /// vCPU count.
117 pub cpu: u32,
118 /// RAM in GiB.
119 #[serde(rename = "memoryGiB")]
120 pub memory_gib: u32,
121 /// Disk in GiB.
122 #[serde(rename = "diskGiB")]
123 pub disk_gib: u32,
124 /// Standard-equivalent multiplier (1, 2, or 4).
125 pub multiplier: u32,
126 }
127
128 /// How an interval claims to have been measured.
129 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
130 #[serde(rename_all = "snake_case")]
131 pub enum MeterBasis {
132 /// Provider confirmed the allocation was actively running.
133 ProviderAcceptedActive,
134 /// Wall-clock elapsed time. Never entitlement.
135 WallClock,
136 }
137
138 /// Immutable pre-dispatch binding. CWC remains the commercial owner; Engine
139 /// refuses to meter anything that is not this record.
140 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141 #[serde(rename_all = "camelCase")]
142 pub struct ComputerAdmission {
143 /// Schema id.
144 pub schema_id: String,
145 /// Caller-supplied admission identity.
146 pub admission_id: String,
147 /// Account that funds the Computer.
148 pub account_id: String,
149 /// Durable Computer id.
150 pub computer_id: String,
151 /// Optional run this admission authorizes.
152 #[serde(default)]
153 pub run_id: String,
154 /// Infrastructure provider (currently `daytona`).
155 pub provider: String,
156 /// Selected v3 profile.
157 pub profile_id: ComputerProfileId,
158 /// Bound vCPU.
159 pub cpu: u32,
160 /// Bound RAM GiB.
161 #[serde(rename = "memoryGiB")]
162 pub memory_gib: u32,
163 /// Bound disk GiB.
164 #[serde(rename = "diskGiB")]
165 pub disk_gib: u32,
166 /// Bound multiplier.
167 pub multiplier: u32,
168 /// Meter revision at bind time.
169 pub meter_revision: String,
170 /// Catalog revision at bind time.
171 pub catalog_revision: String,
172 /// Funding authority (membership included seconds or a time pack).
173 pub funding_authority: String,
174 /// Quote identity bound before dispatch.
175 pub quote_id: String,
176 /// Admission expiry (inclusive bound is refused).
177 pub expires_at: String,
178 /// Digest of the bound fields.
179 pub binding_digest: String,
180 }
181
182 /// Provider-observed allocation at one instant.
183 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
184 #[serde(rename_all = "camelCase")]
185 pub struct ProviderAllocation {
186 /// Provider name.
187 pub provider: String,
188 /// Provider sandbox / allocation id.
189 pub provider_sandbox_id: String,
190 /// Observed vCPU.
191 pub cpu: u32,
192 /// Observed RAM GiB.
193 #[serde(rename = "memoryGiB", alias = "memoryGb")]
194 pub memory_gib: u32,
195 /// Observed disk GiB.
196 #[serde(rename = "diskGiB", alias = "diskGb")]
197 pub disk_gib: u32,
198 /// Provider lifecycle state.
199 pub state: String,
200 /// Whether the provider accepted this as a live allocation.
201 pub accepted: bool,
202 }
203
204 /// One closed provider observation used to mint a receipt.
205 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
206 #[serde(rename_all = "camelCase")]
207 pub struct ProviderObservation {
208 /// Infrastructure provider.
209 pub provider: String,
210 /// Provider sandbox / allocation id.
211 pub provider_sandbox_id: String,
212 /// Idempotent provider event / interval reference.
213 pub provider_event_ref: String,
214 /// Provider lifecycle state.
215 pub state: String,
216 /// True when the allocation is allocated but idle.
217 #[serde(default)]
218 pub idle: bool,
219 /// True only after the provider accepted the live allocation.
220 #[serde(default)]
221 pub provider_accepted: bool,
222 /// Measurement basis. Wall-clock is never entitlement.
223 pub meter_basis: MeterBasis,
224 /// Observed vCPU.
225 pub cpu: u32,
226 /// Observed RAM GiB.
227 #[serde(rename = "memoryGiB", alias = "memoryGb")]
228 pub memory_gib: u32,
229 /// Observed disk GiB.
230 #[serde(rename = "diskGiB", alias = "diskGb")]
231 pub disk_gib: u32,
232 /// Interval start (inclusive).
233 pub started_at: String,
234 /// Interval end (exclusive).
235 pub ended_at: String,
236 }
237
238 /// Immutable receipt for one provider-accepted active interval.
239 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
240 #[serde(rename_all = "camelCase")]
241 pub struct ComputerMeterReceipt {
242 /// Schema id.
243 pub schema_id: String,
244 /// Receipt identity derived from the bound work.
245 pub receipt_id: String,
246 /// Receipt kind.
247 pub kind: String,
248 /// Funding account.
249 pub account_id: String,
250 /// Run, when the interval is run-scoped.
251 #[serde(default)]
252 pub run_id: String,
253 /// Computer id.
254 pub computer_id: String,
255 /// Admission this interval was authorized under.
256 pub admission_id: String,
257 /// Provider name.
258 pub provider: String,
259 /// Profile billed at.
260 pub profile_id: ComputerProfileId,
261 /// Multiplier billed at.
262 pub multiplier: u32,
263 /// Bound vCPU.
264 pub cpu: u32,
265 /// Bound RAM GiB.
266 #[serde(rename = "memoryGiB")]
267 pub memory_gib: u32,
268 /// Bound disk GiB.
269 #[serde(rename = "diskGiB")]
270 pub disk_gib: u32,
271 /// Meter revision.
272 pub meter_revision: String,
273 /// Catalog revision.
274 pub catalog_revision: String,
275 /// Funding authority copied from admission.
276 pub funding_authority: String,
277 /// Quote identity copied from admission.
278 pub quote_id: String,
279 /// Interval start.
280 pub started_at: String,
281 /// Interval end.
282 pub ended_at: String,
283 /// Provider-accepted active whole seconds.
284 pub accepted_seconds: u64,
285 /// `accepted_seconds * multiplier`.
286 pub standard_equivalent_seconds: u64,
287 /// Allocation snapshot the provider accepted.
288 pub provider_allocation: ProviderAllocation,
289 /// Provider event / interval reference.
290 pub provider_event_ref: String,
291 /// Prior receipt this exact replay matched.
292 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub replay_of: Option<String>,
294 /// Original receipt this append-only correction restates.
295 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub correction_of: Option<String>,
297 /// Prior receipt ids in this lineage.
298 #[serde(default)]
299 pub lineage: Vec<String>,
300 /// Digest of the bound receipt fields.
301 pub binding_digest: String,
302 }
303
304 /// Inputs required to bind an admission before dispatch.
305 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
306 #[serde(rename_all = "camelCase")]
307 pub struct ComputerAdmissionRequest {
308 /// Caller-supplied admission identity.
309 pub admission_id: String,
310 /// Account that funds the Computer.
311 pub account_id: String,
312 /// Durable Computer id.
313 pub computer_id: String,
314 /// Optional run.
315 #[serde(default)]
316 pub run_id: String,
317 /// Infrastructure provider.
318 pub provider: String,
319 /// Selected profile id (`standard-8` / `standard-16` / `standard-32`).
320 pub profile_id: String,
321 /// Funding authority.
322 pub funding_authority: String,
323 /// Quote identity.
324 pub quote_id: String,
325 /// Expiry timestamp.
326 pub expires_at: String,
327 /// Optional meter revision; must match v3 when supplied.
328 #[serde(default)]
329 pub meter_revision: String,
330 /// Optional catalog revision; must match v3 when supplied.
331 #[serde(default)]
332 pub catalog_revision: String,
333 }
334
335 /// Fail-closed Computer meter errors.
336 #[derive(Debug, Clone, PartialEq, Eq, Error)]
337 pub enum ComputerMeterError {
338 /// Profile id is not a v3 launch profile.
339 #[error("{message}")]
340 ProfileUnknown { message: String },
341 /// Historic Large/XL/Auto/standard aliases cannot admit new work.
342 #[error("{message}")]
343 HistoricProfileNotAdmissible { message: String },
344 /// Required identity field is missing or hostile.
345 #[error("{message}")]
346 ReferenceInvalid { message: String },
347 /// Timestamp is not RFC 3339.
348 #[error("{message}")]
349 TimestampInvalid { message: String },
350 /// Interval ends before it starts.
351 #[error("A Computer meter interval cannot end before it starts.")]
352 IntervalReversed,
353 /// Admission has expired before the interval started.
354 #[error("The Computer admission expired before this interval.")]
355 AdmissionExpired,
356 /// Meter or catalog revision is not the pinned v3 revision.
357 #[error("{message}")]
358 RevisionMismatch { message: String },
359 /// Caller asked to meter wall-clock, including idle wall-clock.
360 #[error(
361 "Computer entitlement meters provider-accepted active seconds only, never wall-clock-if-idle."
362 )]
363 WallClockIdle,
364 /// Interval is not a provider-accepted active state.
365 #[error("{message}")]
366 NotProviderAcceptedActive { message: String },
367 /// Observed allocation is not exactly the admitted profile.
368 #[error("{message}")]
369 AllocationMismatch { message: String },
370 /// Replay disagrees with the original bound receipt.
371 #[error("This Computer meter receipt is already bound to different terms.")]
372 ReplayConflict,
373 }
374
375 impl ComputerMeterError {
376 /// Stable error code for tests and CWC.
377 #[must_use]
378 pub fn code(&self) -> &'static str {
379 match self {
380 Self::ProfileUnknown { .. } => "computer_profile_unknown",
381 Self::HistoricProfileNotAdmissible { .. } => "computer_profile_historic_not_admissible",
382 Self::ReferenceInvalid { .. } => "computer_meter_reference_invalid",
383 Self::TimestampInvalid { .. } => "computer_meter_timestamp_invalid",
384 Self::IntervalReversed => "computer_meter_interval_reversed",
385 Self::AdmissionExpired => "computer_admission_expired",
386 Self::RevisionMismatch { .. } => "computer_meter_revision_mismatch",
387 Self::WallClockIdle => "computer_meter_wall_clock_idle",
388 Self::NotProviderAcceptedActive { .. } => "computer_meter_not_provider_accepted_active",
389 Self::AllocationMismatch { .. } => "computer_meter_allocation_mismatch",
390 Self::ReplayConflict => "computer_meter_receipt_replay_conflict",
391 }
392 }
393 }
394
395 /// Decode a profile for reading historic or v3 ids. Does not authorize admission.
396 pub fn decode_computer_profile(input: &str) -> Result<ComputerProfile, ComputerMeterError> {
397 match normalize_token(input).as_str() {
398 "standard-8" | "standard" | "8" | "8gb" | "8gib" => Ok(COMPUTER_PROFILES[0]),
399 "standard-16" | "large" | "16" | "16gb" | "16gib" => Ok(COMPUTER_PROFILES[1]),
400 "standard-32" | "xl" | "32" | "32gb" | "32gib" => Ok(COMPUTER_PROFILES[2]),
401 other => Err(ComputerMeterError::ProfileUnknown {
402 message: format!("Unknown Computer profile: {other}."),
403 }),
404 }
405 }
406
407 /// Resolve a profile that may be used to create, quote, resume, or bill v3 work.
408 pub fn admit_computer_profile(input: &str) -> Result<ComputerProfile, ComputerMeterError> {
409 let normalized = normalize_token(input);
410 match normalized.as_str() {
411 "standard-8" | "standard-16" | "standard-32" => decode_computer_profile(&normalized),
412 "standard" | "large" | "xl" | "auto" => {
413 Err(ComputerMeterError::HistoricProfileNotAdmissible {
414 message: format!(
415 "Historic Computer profile `{normalized}` remains readable but cannot admit, resume, or meter new v3 work."
416 ),
417 })
418 }
419 other => Err(ComputerMeterError::ProfileUnknown {
420 message: format!("Unknown Computer profile: {other}."),
421 }),
422 }
423 }
424
425 /// Bind provider, profile, resources, multiplier, revisions, account, funding,
426 /// quote, and expiry before dispatch.
427 pub fn bind_computer_admission(
428 request: ComputerAdmissionRequest,
429 ) -> Result<ComputerAdmission, ComputerMeterError> {
430 let profile = admit_computer_profile(&request.profile_id)?;
431 let admission_id = require_ref(&request.admission_id, "admissionId")?;
432 let account_id = require_ref(&request.account_id, "accountId")?;
433 let computer_id = require_ref(&request.computer_id, "computerId")?;
434 let run_id = optional_ref(&request.run_id, "runId")?;
435 let provider = require_ref(&request.provider, "provider")?;
436 let funding_authority = require_ref(&request.funding_authority, "fundingAuthority")?;
437 let quote_id = require_ref(&request.quote_id, "quoteId")?;
438 let expires_at = normalize_timestamp(&request.expires_at, "expiresAt")?;
439 let meter_revision = require_revision(&request.meter_revision, COMPUTER_METER_REVISION)?;
440 let catalog_revision = require_revision(&request.catalog_revision, COMPUTER_CATALOG_REVISION)?;
441 let bound = ComputerAdmission {
442 schema_id: COMPUTER_ADMISSION_SCHEMA.to_string(),
443 admission_id,
444 account_id,
445 computer_id,
446 run_id,
447 provider,
448 profile_id: profile.id,
449 cpu: profile.cpu,
450 memory_gib: profile.memory_gib,
451 disk_gib: profile.disk_gib,
452 multiplier: profile.multiplier,
453 meter_revision,
454 catalog_revision,
455 funding_authority,
456 quote_id,
457 expires_at,
458 binding_digest: String::new(),
459 };
460 let binding_digest = admission_binding_digest(&bound);
461 Ok(ComputerAdmission {
462 binding_digest,
463 ..bound
464 })
465 }
466
467 /// Mint an immutable receipt for one provider-accepted active interval.
468 pub fn issue_computer_meter_receipt(
469 admission: &ComputerAdmission,
470 observation: ProviderObservation,
471 ) -> Result<ComputerMeterReceipt, ComputerMeterError> {
472 assert_active_observation(&observation)?;
473 assert_allocation_matches(admission, &observation)?;
474 let started_at = normalize_timestamp(&observation.started_at, "startedAt")?;
475 let ended_at = normalize_timestamp(&observation.ended_at, "endedAt")?;
476 let started = parse_timestamp(&started_at, "startedAt")?;
477 let ended = parse_timestamp(&ended_at, "endedAt")?;
478 if ended < started {
479 return Err(ComputerMeterError::IntervalReversed);
480 }
481 let expires = parse_timestamp(&admission.expires_at, "expiresAt")?;
482 if started >= expires {
483 return Err(ComputerMeterError::AdmissionExpired);
484 }
485 let accepted_seconds = elapsed_whole_seconds(started, ended);
486 let standard_equivalent_seconds =
487 accepted_seconds.saturating_mul(u64::from(admission.multiplier));
488 let provider = require_ref(&observation.provider, "provider")?;
489 if provider != admission.provider {
490 return Err(ComputerMeterError::AllocationMismatch {
491 message: format!(
492 "Provider `{}` does not match admitted provider `{}`.",
493 provider, admission.provider
494 ),
495 });
496 }
497 let provider_sandbox_id = require_ref(&observation.provider_sandbox_id, "providerSandboxId")?;
498 let provider_event_ref = require_ref(&observation.provider_event_ref, "providerEventRef")?;
499 let allocation = ProviderAllocation {
500 provider: provider.clone(),
501 provider_sandbox_id: provider_sandbox_id.clone(),
502 cpu: observation.cpu,
503 memory_gib: observation.memory_gib,
504 disk_gib: observation.disk_gib,
505 state: normalize_token(&observation.state),
506 accepted: true,
507 };
508 let mut receipt = ComputerMeterReceipt {
509 schema_id: COMPUTER_METER_RECEIPT_SCHEMA.to_string(),
510 receipt_id: String::new(),
511 kind: COMPUTER_METER_RECEIPT_KIND.to_string(),
512 account_id: admission.account_id.clone(),
513 run_id: admission.run_id.clone(),
514 computer_id: admission.computer_id.clone(),
515 admission_id: admission.admission_id.clone(),
516 provider,
517 profile_id: admission.profile_id,
518 multiplier: admission.multiplier,
519 cpu: admission.cpu,
520 memory_gib: admission.memory_gib,
521 disk_gib: admission.disk_gib,
522 meter_revision: admission.meter_revision.clone(),
523 catalog_revision: admission.catalog_revision.clone(),
524 funding_authority: admission.funding_authority.clone(),
525 quote_id: admission.quote_id.clone(),
526 started_at,
527 ended_at,
528 accepted_seconds,
529 standard_equivalent_seconds,
530 provider_allocation: allocation,
531 provider_event_ref,
532 replay_of: None,
533 correction_of: None,
534 lineage: Vec::new(),
535 binding_digest: String::new(),
536 };
537 receipt.binding_digest = receipt_binding_digest(&receipt);
538 receipt.receipt_id = receipt_id_for(&receipt);
539 Ok(receipt)
540 }
541
542 /// Exact replay of an existing receipt. Identity and digest must match.
543 pub fn assert_computer_meter_receipt_replay(
544 existing: &ComputerMeterReceipt,
545 incoming: &ComputerMeterReceipt,
546 ) -> Result<(), ComputerMeterError> {
547 if existing.receipt_id == incoming.receipt_id
548 && existing.binding_digest == incoming.binding_digest
549 && existing.admission_id == incoming.admission_id
550 && existing.account_id == incoming.account_id
551 && existing.provider_event_ref == incoming.provider_event_ref
552 && existing.accepted_seconds == incoming.accepted_seconds
553 && existing.standard_equivalent_seconds == incoming.standard_equivalent_seconds
554 && existing.profile_id == incoming.profile_id
555 && existing.multiplier == incoming.multiplier
556 {
557 return Ok(());
558 }
559 Err(ComputerMeterError::ReplayConflict)
560 }
561
562 /// Append-only correction. The original receipt is not mutated.
563 pub fn correct_computer_meter_receipt(
564 original: &ComputerMeterReceipt,
565 admission: &ComputerAdmission,
566 observation: ProviderObservation,
567 ) -> Result<ComputerMeterReceipt, ComputerMeterError> {
568 if original.admission_id != admission.admission_id
569 || original.account_id != admission.account_id
570 {
571 return Err(ComputerMeterError::ReplayConflict);
572 }
573 let mut correction = issue_computer_meter_receipt(admission, observation)?;
574 if correction.profile_id != original.profile_id || correction.multiplier != original.multiplier
575 {
576 return Err(ComputerMeterError::ReplayConflict);
577 }
578 correction.correction_of = Some(original.receipt_id.clone());
579 correction.lineage = {
580 let mut lineage = original.lineage.clone();
581 lineage.push(original.receipt_id.clone());
582 lineage
583 };
584 correction.binding_digest = receipt_binding_digest(&correction);
585 correction.receipt_id = receipt_id_for(&correction);
586 Ok(correction)
587 }
588
589 /// Sum Standard-equivalent seconds across independent Computer receipts.
590 #[must_use]
591 pub fn sum_standard_equivalent_seconds(receipts: &[ComputerMeterReceipt]) -> u64 {
592 receipts
593 .iter()
594 .map(|receipt| receipt.standard_equivalent_seconds)
595 .fold(0, u64::saturating_add)
596 }
597
598 fn assert_active_observation(observation: &ProviderObservation) -> Result<(), ComputerMeterError> {
599 if observation.meter_basis != MeterBasis::ProviderAcceptedActive || observation.idle {
600 return Err(ComputerMeterError::WallClockIdle);
601 }
602 if !observation.provider_accepted {
603 return Err(ComputerMeterError::NotProviderAcceptedActive {
604 message: "A Computer meter receipt requires a provider-accepted live allocation."
605 .to_string(),
606 });
607 }
608 let state = normalize_token(&observation.state);
609 if !matches!(state.as_str(), "running" | "started" | "active") {
610 return Err(ComputerMeterError::NotProviderAcceptedActive {
611 message: format!(
612 "Computer entitlement does not accrue in `{state}` (requested, queued, rejected, stopped, suspended, archived, failed-before-acceptance, and teardown-tail are excluded)."
613 ),
614 });
615 }
616 Ok(())
617 }
618
619 fn assert_allocation_matches(
620 admission: &ComputerAdmission,
621 observation: &ProviderObservation,
622 ) -> Result<(), ComputerMeterError> {
623 if observation.cpu == admission.cpu
624 && observation.memory_gib == admission.memory_gib
625 && observation.disk_gib == admission.disk_gib
626 {
627 return Ok(());
628 }
629 Err(ComputerMeterError::AllocationMismatch {
630 message: format!(
631 "Provider allocation {} vCPU / {} GiB RAM / {} GiB disk does not equal admitted profile {} ({} / {} / {}). Smaller is not a cost-saving substitution and larger is not an upgrade.",
632 observation.cpu,
633 observation.memory_gib,
634 observation.disk_gib,
635 admission.profile_id.as_str(),
636 admission.cpu,
637 admission.memory_gib,
638 admission.disk_gib
639 ),
640 })
641 }
642
643 fn admission_binding_digest(admission: &ComputerAdmission) -> String {
644 sha256_hex(
645 [
646 ADMISSION_DIGEST_NS,
647 admission.admission_id.as_str(),
648 admission.account_id.as_str(),
649 admission.computer_id.as_str(),
650 admission.run_id.as_str(),
651 admission.provider.as_str(),
652 admission.profile_id.as_str(),
653 &admission.cpu.to_string(),
654 &admission.memory_gib.to_string(),
655 &admission.disk_gib.to_string(),
656 &admission.multiplier.to_string(),
657 admission.meter_revision.as_str(),
658 admission.catalog_revision.as_str(),
659 admission.funding_authority.as_str(),
660 admission.quote_id.as_str(),
661 admission.expires_at.as_str(),
662 ]
663 .join("\0"),
664 )
665 }
666
667 fn receipt_binding_digest(receipt: &ComputerMeterReceipt) -> String {
668 sha256_hex(
669 [
670 RECEIPT_DIGEST_NS,
671 receipt.account_id.as_str(),
672 receipt.run_id.as_str(),
673 receipt.computer_id.as_str(),
674 receipt.admission_id.as_str(),
675 receipt.provider.as_str(),
676 receipt.profile_id.as_str(),
677 &receipt.multiplier.to_string(),
678 &receipt.cpu.to_string(),
679 &receipt.memory_gib.to_string(),
680 &receipt.disk_gib.to_string(),
681 receipt.meter_revision.as_str(),
682 receipt.catalog_revision.as_str(),
683 receipt.funding_authority.as_str(),
684 receipt.quote_id.as_str(),
685 receipt.started_at.as_str(),
686 receipt.ended_at.as_str(),
687 &receipt.accepted_seconds.to_string(),
688 &receipt.standard_equivalent_seconds.to_string(),
689 receipt.provider_event_ref.as_str(),
690 receipt.provider_allocation.provider_sandbox_id.as_str(),
691 &receipt.provider_allocation.cpu.to_string(),
692 &receipt.provider_allocation.memory_gib.to_string(),
693 &receipt.provider_allocation.disk_gib.to_string(),
694 receipt.provider_allocation.state.as_str(),
695 receipt.correction_of.as_deref().unwrap_or(""),
696 &receipt.lineage.join(","),
697 ]
698 .join("\0"),
699 )
700 }
701
702 fn receipt_id_for(receipt: &ComputerMeterReceipt) -> String {
703 format!("cmr_{}", &receipt.binding_digest[..32])
704 }
705
706 fn elapsed_whole_seconds(started: DateTime<Utc>, ended: DateTime<Utc>) -> u64 {
707 ended
708 .signed_duration_since(started)
709 .num_milliseconds()
710 .max(0)
711 .unsigned_abs()
712 / 1000
713 }
714
715 fn require_revision(value: &str, expected: &str) -> Result<String, ComputerMeterError> {
716 let normalized = value.trim();
717 if normalized.is_empty() {
718 return Ok(expected.to_string());
719 }
720 if normalized == expected {
721 return Ok(expected.to_string());
722 }
723 Err(ComputerMeterError::RevisionMismatch {
724 message: format!("Computer meter revision `{normalized}` is not {expected}."),
725 })
726 }
727
728 fn require_ref(value: &str, field: &str) -> Result<String, ComputerMeterError> {
729 let normalized = value.trim();
730 if normalized.is_empty()
731 || normalized.len() > MAX_REF_CHARS
732 || normalized.chars().any(|ch| ch.is_control())
733 {
734 return Err(ComputerMeterError::ReferenceInvalid {
735 message: format!(
736 "Computer meter field `{field}` is required and must be a bounded token."
737 ),
738 });
739 }
740 Ok(normalized.to_string())
741 }
742
743 fn optional_ref(value: &str, field: &str) -> Result<String, ComputerMeterError> {
744 let normalized = value.trim();
745 if normalized.is_empty() {
746 return Ok(String::new());
747 }
748 require_ref(normalized, field)
749 }
750
751 fn normalize_token(value: &str) -> String {
752 value.trim().to_ascii_lowercase()
753 }
754
755 fn normalize_timestamp(value: &str, field: &str) -> Result<String, ComputerMeterError> {
756 Ok(parse_timestamp(value, field)?.to_rfc3339_opts(SecondsFormat::Millis, true))
757 }
758
759 fn parse_timestamp(value: &str, field: &str) -> Result<DateTime<Utc>, ComputerMeterError> {
760 DateTime::parse_from_rfc3339(value.trim())
761 .map(|parsed| parsed.with_timezone(&Utc))
762 .map_err(|_| ComputerMeterError::TimestampInvalid {
763 message: format!("Computer meter field `{field}` must be an RFC 3339 timestamp."),
764 })
765 }
766
767 #[cfg(test)]
768 mod tests;
769
769 lines RUST