返回 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 expired before the interval ended. An interval must lie
354 /// wholly inside the admission; the tail after expiry was never authorized.
355 #[error("The Computer admission expired before this interval ended.")]
356 AdmissionExpired,
357 /// Meter or catalog revision is not the pinned v3 revision.
358 #[error("{message}")]
359 RevisionMismatch { message: String },
360 /// Caller asked to meter wall-clock, including idle wall-clock.
361 #[error(
362 "Computer entitlement meters provider-accepted active seconds only, never wall-clock-if-idle."
363 )]
364 WallClockIdle,
365 /// Interval is not a provider-accepted active state.
366 #[error("{message}")]
367 NotProviderAcceptedActive { message: String },
368 /// Observed allocation is not exactly the admitted profile.
369 #[error("{message}")]
370 AllocationMismatch { message: String },
371 /// Replay disagrees with the original bound receipt.
372 #[error("This Computer meter receipt is already bound to different terms.")]
373 ReplayConflict,
374 }
375
376 impl ComputerMeterError {
377 /// Stable error code for tests and CWC.
378 #[must_use]
379 pub fn code(&self) -> &'static str {
380 match self {
381 Self::ProfileUnknown { .. } => "computer_profile_unknown",
382 Self::HistoricProfileNotAdmissible { .. } => "computer_profile_historic_not_admissible",
383 Self::ReferenceInvalid { .. } => "computer_meter_reference_invalid",
384 Self::TimestampInvalid { .. } => "computer_meter_timestamp_invalid",
385 Self::IntervalReversed => "computer_meter_interval_reversed",
386 Self::AdmissionExpired => "computer_admission_expired",
387 Self::RevisionMismatch { .. } => "computer_meter_revision_mismatch",
388 Self::WallClockIdle => "computer_meter_wall_clock_idle",
389 Self::NotProviderAcceptedActive { .. } => "computer_meter_not_provider_accepted_active",
390 Self::AllocationMismatch { .. } => "computer_meter_allocation_mismatch",
391 Self::ReplayConflict => "computer_meter_receipt_replay_conflict",
392 }
393 }
394 }
395
396 /// Decode a profile for reading historic or v3 ids. Does not authorize admission.
397 pub fn decode_computer_profile(input: &str) -> Result<ComputerProfile, ComputerMeterError> {
398 match normalize_token(input).as_str() {
399 "standard-8" | "standard" | "8" | "8gb" | "8gib" => Ok(COMPUTER_PROFILES[0]),
400 "standard-16" | "large" | "16" | "16gb" | "16gib" => Ok(COMPUTER_PROFILES[1]),
401 "standard-32" | "xl" | "32" | "32gb" | "32gib" => Ok(COMPUTER_PROFILES[2]),
402 other => Err(ComputerMeterError::ProfileUnknown {
403 message: format!("Unknown Computer profile: {other}."),
404 }),
405 }
406 }
407
408 /// Resolve a profile that may be used to create, quote, resume, or bill v3 work.
409 pub fn admit_computer_profile(input: &str) -> Result<ComputerProfile, ComputerMeterError> {
410 let normalized = normalize_token(input);
411 match normalized.as_str() {
412 "standard-8" | "standard-16" | "standard-32" => decode_computer_profile(&normalized),
413 "standard" | "large" | "xl" | "auto" => {
414 Err(ComputerMeterError::HistoricProfileNotAdmissible {
415 message: format!(
416 "Historic Computer profile `{normalized}` remains readable but cannot admit, resume, or meter new v3 work."
417 ),
418 })
419 }
420 other => Err(ComputerMeterError::ProfileUnknown {
421 message: format!("Unknown Computer profile: {other}."),
422 }),
423 }
424 }
425
426 /// Bind provider, profile, resources, multiplier, revisions, account, funding,
427 /// quote, and expiry before dispatch.
428 pub fn bind_computer_admission(
429 request: ComputerAdmissionRequest,
430 ) -> Result<ComputerAdmission, ComputerMeterError> {
431 let profile = admit_computer_profile(&request.profile_id)?;
432 let admission_id = require_ref(&request.admission_id, "admissionId")?;
433 let account_id = require_ref(&request.account_id, "accountId")?;
434 let computer_id = require_ref(&request.computer_id, "computerId")?;
435 let run_id = optional_ref(&request.run_id, "runId")?;
436 let provider = require_ref(&request.provider, "provider")?;
437 let funding_authority = require_ref(&request.funding_authority, "fundingAuthority")?;
438 let quote_id = require_ref(&request.quote_id, "quoteId")?;
439 let expires_at = normalize_timestamp(&request.expires_at, "expiresAt")?;
440 let meter_revision = require_revision(&request.meter_revision, COMPUTER_METER_REVISION)?;
441 let catalog_revision = require_revision(&request.catalog_revision, COMPUTER_CATALOG_REVISION)?;
442 let bound = ComputerAdmission {
443 schema_id: COMPUTER_ADMISSION_SCHEMA.to_string(),
444 admission_id,
445 account_id,
446 computer_id,
447 run_id,
448 provider,
449 profile_id: profile.id,
450 cpu: profile.cpu,
451 memory_gib: profile.memory_gib,
452 disk_gib: profile.disk_gib,
453 multiplier: profile.multiplier,
454 meter_revision,
455 catalog_revision,
456 funding_authority,
457 quote_id,
458 expires_at,
459 binding_digest: String::new(),
460 };
461 let binding_digest = admission_binding_digest(&bound);
462 Ok(ComputerAdmission {
463 binding_digest,
464 ..bound
465 })
466 }
467
468 /// Mint an immutable receipt for one provider-accepted active interval.
469 pub fn issue_computer_meter_receipt(
470 admission: &ComputerAdmission,
471 observation: ProviderObservation,
472 ) -> Result<ComputerMeterReceipt, ComputerMeterError> {
473 assert_active_observation(&observation)?;
474 assert_allocation_matches(admission, &observation)?;
475 let started_at = normalize_timestamp(&observation.started_at, "startedAt")?;
476 let ended_at = normalize_timestamp(&observation.ended_at, "endedAt")?;
477 let started = parse_timestamp(&started_at, "startedAt")?;
478 let ended = parse_timestamp(&ended_at, "endedAt")?;
479 if ended < started {
480 return Err(ComputerMeterError::IntervalReversed);
481 }
482 let expires = parse_timestamp(&admission.expires_at, "expiresAt")?;
483 // `ended_at` is exclusive, so an interval ending exactly at expiry is
484 // wholly authorized; one that runs past it is refused rather than clipped,
485 // because a clipped receipt would no longer restate the provider interval.
486 if started >= expires || ended > expires {
487 return Err(ComputerMeterError::AdmissionExpired);
488 }
489 let accepted_seconds = elapsed_whole_seconds(started, ended);
490 let standard_equivalent_seconds =
491 accepted_seconds.saturating_mul(u64::from(admission.multiplier));
492 let provider = require_ref(&observation.provider, "provider")?;
493 if provider != admission.provider {
494 return Err(ComputerMeterError::AllocationMismatch {
495 message: format!(
496 "Provider `{}` does not match admitted provider `{}`.",
497 provider, admission.provider
498 ),
499 });
500 }
501 let provider_sandbox_id = require_ref(&observation.provider_sandbox_id, "providerSandboxId")?;
502 let provider_event_ref = require_ref(&observation.provider_event_ref, "providerEventRef")?;
503 let allocation = ProviderAllocation {
504 provider: provider.clone(),
505 provider_sandbox_id: provider_sandbox_id.clone(),
506 cpu: observation.cpu,
507 memory_gib: observation.memory_gib,
508 disk_gib: observation.disk_gib,
509 state: normalize_token(&observation.state),
510 accepted: true,
511 };
512 let mut receipt = ComputerMeterReceipt {
513 schema_id: COMPUTER_METER_RECEIPT_SCHEMA.to_string(),
514 receipt_id: String::new(),
515 kind: COMPUTER_METER_RECEIPT_KIND.to_string(),
516 account_id: admission.account_id.clone(),
517 run_id: admission.run_id.clone(),
518 computer_id: admission.computer_id.clone(),
519 admission_id: admission.admission_id.clone(),
520 provider,
521 profile_id: admission.profile_id,
522 multiplier: admission.multiplier,
523 cpu: admission.cpu,
524 memory_gib: admission.memory_gib,
525 disk_gib: admission.disk_gib,
526 meter_revision: admission.meter_revision.clone(),
527 catalog_revision: admission.catalog_revision.clone(),
528 funding_authority: admission.funding_authority.clone(),
529 quote_id: admission.quote_id.clone(),
530 started_at,
531 ended_at,
532 accepted_seconds,
533 standard_equivalent_seconds,
534 provider_allocation: allocation,
535 provider_event_ref,
536 replay_of: None,
537 correction_of: None,
538 lineage: Vec::new(),
539 binding_digest: String::new(),
540 };
541 receipt.binding_digest = receipt_binding_digest(&receipt);
542 receipt.receipt_id = receipt_id_for(&receipt);
543 Ok(receipt)
544 }
545
546 /// Exact replay of an existing receipt. Every field must match (only the
547 /// `replay_of` back-reference may differ), and the incoming digest and id must
548 /// be the ones its own fields produce: a caller-supplied digest is never
549 /// trusted to stand for fields it does not cover.
550 pub fn assert_computer_meter_receipt_replay(
551 existing: &ComputerMeterReceipt,
552 incoming: &ComputerMeterReceipt,
553 ) -> Result<(), ComputerMeterError> {
554 let self_consistent = receipt_binding_digest(incoming) == incoming.binding_digest
555 && receipt_id_for(incoming) == incoming.receipt_id;
556 let same_terms = ComputerMeterReceipt {
557 replay_of: existing.replay_of.clone(),
558 ..incoming.clone()
559 } == *existing;
560 if self_consistent && same_terms {
561 return Ok(());
562 }
563 Err(ComputerMeterError::ReplayConflict)
564 }
565
566 /// Append-only correction. The original receipt is not mutated.
567 pub fn correct_computer_meter_receipt(
568 original: &ComputerMeterReceipt,
569 admission: &ComputerAdmission,
570 observation: ProviderObservation,
571 ) -> Result<ComputerMeterReceipt, ComputerMeterError> {
572 if original.admission_id != admission.admission_id
573 || original.account_id != admission.account_id
574 {
575 return Err(ComputerMeterError::ReplayConflict);
576 }
577 let mut correction = issue_computer_meter_receipt(admission, observation)?;
578 if correction.profile_id != original.profile_id || correction.multiplier != original.multiplier
579 {
580 return Err(ComputerMeterError::ReplayConflict);
581 }
582 correction.correction_of = Some(original.receipt_id.clone());
583 correction.lineage = {
584 let mut lineage = original.lineage.clone();
585 lineage.push(original.receipt_id.clone());
586 lineage
587 };
588 correction.binding_digest = receipt_binding_digest(&correction);
589 correction.receipt_id = receipt_id_for(&correction);
590 Ok(correction)
591 }
592
593 /// Sum Standard-equivalent seconds across independent Computer receipts.
594 #[must_use]
595 pub fn sum_standard_equivalent_seconds(receipts: &[ComputerMeterReceipt]) -> u64 {
596 receipts
597 .iter()
598 .map(|receipt| receipt.standard_equivalent_seconds)
599 .fold(0, u64::saturating_add)
600 }
601
602 fn assert_active_observation(observation: &ProviderObservation) -> Result<(), ComputerMeterError> {
603 if observation.meter_basis != MeterBasis::ProviderAcceptedActive || observation.idle {
604 return Err(ComputerMeterError::WallClockIdle);
605 }
606 if !observation.provider_accepted {
607 return Err(ComputerMeterError::NotProviderAcceptedActive {
608 message: "A Computer meter receipt requires a provider-accepted live allocation."
609 .to_string(),
610 });
611 }
612 let state = normalize_token(&observation.state);
613 if !matches!(state.as_str(), "running" | "started" | "active") {
614 return Err(ComputerMeterError::NotProviderAcceptedActive {
615 message: format!(
616 "Computer entitlement does not accrue in `{state}` (requested, queued, rejected, stopped, suspended, archived, failed-before-acceptance, and teardown-tail are excluded)."
617 ),
618 });
619 }
620 Ok(())
621 }
622
623 fn assert_allocation_matches(
624 admission: &ComputerAdmission,
625 observation: &ProviderObservation,
626 ) -> Result<(), ComputerMeterError> {
627 if observation.cpu == admission.cpu
628 && observation.memory_gib == admission.memory_gib
629 && observation.disk_gib == admission.disk_gib
630 {
631 return Ok(());
632 }
633 Err(ComputerMeterError::AllocationMismatch {
634 message: format!(
635 "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.",
636 observation.cpu,
637 observation.memory_gib,
638 observation.disk_gib,
639 admission.profile_id.as_str(),
640 admission.cpu,
641 admission.memory_gib,
642 admission.disk_gib
643 ),
644 })
645 }
646
647 fn admission_binding_digest(admission: &ComputerAdmission) -> String {
648 sha256_hex(
649 [
650 ADMISSION_DIGEST_NS,
651 admission.admission_id.as_str(),
652 admission.account_id.as_str(),
653 admission.computer_id.as_str(),
654 admission.run_id.as_str(),
655 admission.provider.as_str(),
656 admission.profile_id.as_str(),
657 &admission.cpu.to_string(),
658 &admission.memory_gib.to_string(),
659 &admission.disk_gib.to_string(),
660 &admission.multiplier.to_string(),
661 admission.meter_revision.as_str(),
662 admission.catalog_revision.as_str(),
663 admission.funding_authority.as_str(),
664 admission.quote_id.as_str(),
665 admission.expires_at.as_str(),
666 ]
667 .join("\0"),
668 )
669 }
670
671 fn receipt_binding_digest(receipt: &ComputerMeterReceipt) -> String {
672 sha256_hex(
673 [
674 RECEIPT_DIGEST_NS,
675 receipt.account_id.as_str(),
676 receipt.run_id.as_str(),
677 receipt.computer_id.as_str(),
678 receipt.admission_id.as_str(),
679 receipt.provider.as_str(),
680 receipt.profile_id.as_str(),
681 &receipt.multiplier.to_string(),
682 &receipt.cpu.to_string(),
683 &receipt.memory_gib.to_string(),
684 &receipt.disk_gib.to_string(),
685 receipt.meter_revision.as_str(),
686 receipt.catalog_revision.as_str(),
687 receipt.funding_authority.as_str(),
688 receipt.quote_id.as_str(),
689 receipt.started_at.as_str(),
690 receipt.ended_at.as_str(),
691 &receipt.accepted_seconds.to_string(),
692 &receipt.standard_equivalent_seconds.to_string(),
693 receipt.provider_event_ref.as_str(),
694 receipt.provider_allocation.provider_sandbox_id.as_str(),
695 &receipt.provider_allocation.cpu.to_string(),
696 &receipt.provider_allocation.memory_gib.to_string(),
697 &receipt.provider_allocation.disk_gib.to_string(),
698 receipt.provider_allocation.state.as_str(),
699 receipt.correction_of.as_deref().unwrap_or(""),
700 &receipt.lineage.join(","),
701 ]
702 .join("\0"),
703 )
704 }
705
706 fn receipt_id_for(receipt: &ComputerMeterReceipt) -> String {
707 format!("cmr_{}", &receipt.binding_digest[..32])
708 }
709
710 fn elapsed_whole_seconds(started: DateTime<Utc>, ended: DateTime<Utc>) -> u64 {
711 ended
712 .signed_duration_since(started)
713 .num_milliseconds()
714 .max(0)
715 .unsigned_abs()
716 / 1000
717 }
718
719 fn require_revision(value: &str, expected: &str) -> Result<String, ComputerMeterError> {
720 let normalized = value.trim();
721 if normalized.is_empty() {
722 return Ok(expected.to_string());
723 }
724 if normalized == expected {
725 return Ok(expected.to_string());
726 }
727 Err(ComputerMeterError::RevisionMismatch {
728 message: format!("Computer meter revision `{normalized}` is not {expected}."),
729 })
730 }
731
732 fn require_ref(value: &str, field: &str) -> Result<String, ComputerMeterError> {
733 let normalized = value.trim();
734 if normalized.is_empty()
735 || normalized.len() > MAX_REF_CHARS
736 || normalized.chars().any(|ch| ch.is_control())
737 {
738 return Err(ComputerMeterError::ReferenceInvalid {
739 message: format!(
740 "Computer meter field `{field}` is required and must be a bounded token."
741 ),
742 });
743 }
744 Ok(normalized.to_string())
745 }
746
747 fn optional_ref(value: &str, field: &str) -> Result<String, ComputerMeterError> {
748 let normalized = value.trim();
749 if normalized.is_empty() {
750 return Ok(String::new());
751 }
752 require_ref(normalized, field)
753 }
754
755 fn normalize_token(value: &str) -> String {
756 value.trim().to_ascii_lowercase()
757 }
758
759 fn normalize_timestamp(value: &str, field: &str) -> Result<String, ComputerMeterError> {
760 Ok(parse_timestamp(value, field)?.to_rfc3339_opts(SecondsFormat::Millis, true))
761 }
762
763 fn parse_timestamp(value: &str, field: &str) -> Result<DateTime<Utc>, ComputerMeterError> {
764 DateTime::parse_from_rfc3339(value.trim())
765 .map(|parsed| parsed.with_timezone(&Utc))
766 .map_err(|_| ComputerMeterError::TimestampInvalid {
767 message: format!("Computer meter field `{field}` must be an RFC 3339 timestamp."),
768 })
769 }
770
771 #[cfg(test)]
772 mod tests;
773
773 lines RUST