| 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 |