eoc: allocate a facility among tenants, then bind the published band to it #63

Merged
dcharlot merged 3 commits from eoc/multi-tenant-allocation into main 2026-08-29 03:11:42 -04:00
6 changed files with 980 additions and 8 deletions

15
eoc/eoc-rs/Cargo.lock generated
View file

@ -766,6 +766,7 @@ name = "eoc-carbon"
version = "0.1.0"
dependencies = [
"async-trait",
"blake3",
"chrono",
"eoc-core",
"eoc-eval",
@ -1011,7 +1012,7 @@ dependencies = [
"eoc-kv",
"eoc-neural",
"nalgebra",
"rand 0.10.2",
"rand 0.10.1",
"rand_distr",
"serde",
"serde_json",
@ -1043,7 +1044,7 @@ dependencies = [
"eoc-meter",
"eoc-neural",
"proptest",
"rand 0.10.2",
"rand 0.10.1",
"serde",
"serde_json",
"thiserror 2.0.20",
@ -2664,7 +2665,7 @@ dependencies = [
"bytes",
"getrandom 0.4.3",
"lru-slab",
"rand 0.10.2",
"rand 0.10.1",
"rand_pcg",
"ring",
"rustc-hash 2.1.3",
@ -2724,9 +2725,9 @@ dependencies = [
[[package]]
name = "rand"
version = "0.10.2"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
dependencies = [
"chacha20",
"getrandom 0.4.3",
@ -2765,7 +2766,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d431c2703ccf129de4d45253c03f49ebb22b97d6ad79ee3ecfc7e3f4862c1d8"
dependencies = [
"num-traits",
"rand 0.10.2",
"rand 0.10.1",
]
[[package]]
@ -3459,7 +3460,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"getrandom 0.3.4",
"once_cell",
"rustix",
"windows-sys 0.61.2",

View file

@ -17,6 +17,7 @@ eval-bridge = ["dep:eoc-eval"]
[dependencies]
eoc-core.workspace = true
blake3.workspace = true
eoc-meter.workspace = true
serde = { workspace = true }
serde_json = "1"

View file

@ -0,0 +1,551 @@
//! Multi-tenant facility allocation — dividing a **measured facility total**
//! among the tenants that caused it, without losing any of it.
//!
//! # The gap this fills
//!
//! [`crate::account`] accounts one request bottom-up: `it_joules × PUE ×
//! intensity`. That is the right shape for a single tenant and the wrong shape
//! for a facility. Nothing in a bottom-up model forces the tenants' figures to
//! add up to what the building actually drew, so the two failure modes are
//! silent and opposite:
//!
//! - **Under-reporting.** Idle capacity, cooling headroom, the control plane,
//! failed requests and unbilled internal work draw real power and belong to no
//! request, so they simply vanish from the sum.
//! - **Over-reporting.** Two tenants can each be charged a PUE multiplier over
//! the same shared overhead, and nothing notices.
//!
//! An allocation here is therefore anchored **top-down** on a measured total,
//! and the arithmetic is required to close.
//!
//! # Integer microjoules, on purpose
//!
//! [`crate::account::CarbonAccount`] is `f64` throughout, which is fine for a
//! reported figure and unusable for an invariant: a conservation law that holds
//! to within floating-point error is not a conservation law. Allocation uses
//! `u64` microjoules, matching `eoc_core::JouleCost`, so `Σ shares + residual ==
//! total` holds **exactly** and is checked rather than hoped for.
//!
//! # The residual is named, never dropped
//!
//! [`Allocation`] requires an explicit `residual_uj`. An allocation that assigns
//! 100% of a facility to its tenants is asserting that every joule the building
//! drew was attributable to a paying workload, which is essentially never true.
//! Making the remainder a required field means an operator has to state it
//! rather than let it disappear into rounding.
//!
//! # What a tenant can check
//!
//! Tenant `T` wants to confirm its own figure was derived from the real total,
//! without being shown its co-tenants' consumption. The shares are committed to
//! a **Merkle sum tree**: every node carries a hash *and* the sum of its
//! subtree. `T` receives its own leaf and the sibling `(hash, sum)` pairs along
//! its path, recomputes the root, and checks that the root's sum plus the
//! residual equals the signed facility total.
//!
//! What that leaks is stated plainly rather than glossed: `T` learns the
//! **aggregate** of each sibling subtree along its path — with `n` tenants,
//! roughly `log2(n)` partial sums. It does not learn any individual co-tenant's
//! share or identity. Hiding the aggregates too needs a zero-knowledge range
//! proof, which this module does not attempt and does not pretend to.
use blake3::Hasher;
use serde::{Deserialize, Serialize};
const DOMAIN_LEAF: &[u8] = b"eoc:alloc-leaf\x01";
const DOMAIN_NODE: &[u8] = b"eoc:alloc-node\x01";
/// Errors an allocation can fail with.
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum AllocationError {
/// Shares plus residual do not equal the facility total.
#[error(
"shares + residual = {got} µJ but the facility drew {total} µJ — an allocation that does not close is not an allocation"
)]
NotConserved {
/// What the parts actually summed to.
got: u128,
/// What the facility measured.
total: u64,
},
/// Tenants were charged for more energy than the facility drew.
#[error("shares total {shares} µJ, more than the facility's {total} µJ")]
OverAllocated {
/// The summed tenant shares.
shares: u128,
/// What the facility measured.
total: u64,
},
/// An allocation with no tenants allocates nothing.
#[error("no tenant shares supplied")]
NoShares,
/// The same tenant appears twice, which would charge it twice.
#[error("tenant {0:?} appears more than once")]
DuplicateTenant(String),
/// The accounting period runs backwards.
#[error("the period ends before it starts")]
BadPeriod,
}
/// How the shares were derived. A disclosed choice, never a default: the same
/// facility split by measured energy, by core-hours, or by revenue produces
/// three different numbers for the same tenant, all defensible and none
/// comparable. An allocation that does not say which was used cannot be checked
/// against another.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Basis {
/// In proportion to each tenant's measured IT energy.
MeasuredEnergy,
/// In proportion to allocated compute time (core- or device-seconds).
ComputeSeconds,
/// In proportion to reserved capacity, whether or not it was used.
ReservedCapacity,
/// Something else, named by the operator.
Declared,
}
impl Basis {
/// The wire label for this basis.
pub fn label(self) -> &'static str {
match self {
Basis::MeasuredEnergy => "measured_energy",
Basis::ComputeSeconds => "compute_seconds",
Basis::ReservedCapacity => "reserved_capacity",
Basis::Declared => "declared",
}
}
}
/// The measured draw of a whole facility over one accounting period.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FacilityTotal {
/// Which facility this total is for.
pub facility_id: String,
/// Start of the accounting period (microseconds since the epoch).
pub period_start_us: u64,
/// End of the accounting period (microseconds since the epoch).
pub period_end_us: u64,
/// Measured facility-layer energy for the period, integer microjoules.
pub facility_uj: u64,
}
/// One tenant's allocated share.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TenantShare {
/// The tenant this share is allocated to.
pub tenant: String,
/// The allocated energy, integer microjoules.
pub uj: u64,
}
impl TenantShare {
fn leaf_hash(&self) -> [u8; 32] {
let mut h = Hasher::new();
h.update(DOMAIN_LEAF);
h.update(&(self.tenant.len() as u64).to_be_bytes());
h.update(self.tenant.as_bytes());
h.update(&self.uj.to_be_bytes());
*h.finalize().as_bytes()
}
}
/// A node in the Merkle **sum** tree: a hash and the total of its subtree.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SumNode {
/// BLAKE3 over this subtree.
pub hash: [u8; 32],
/// Total microjoules committed beneath this node.
pub sum_uj: u64,
}
fn combine(l: &SumNode, r: &SumNode) -> SumNode {
let mut h = Hasher::new();
h.update(DOMAIN_NODE);
h.update(&l.hash);
h.update(&l.sum_uj.to_be_bytes());
h.update(&r.hash);
h.update(&r.sum_uj.to_be_bytes());
SumNode {
hash: *h.finalize().as_bytes(),
// Sums are bounded by the facility total, which the constructor has
// already checked, so this cannot overflow in a valid allocation.
sum_uj: l.sum_uj.saturating_add(r.sum_uj),
}
}
/// A facility total divided among tenants, with the remainder named.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Allocation {
/// The measured facility draw being divided.
pub total: FacilityTotal,
/// How the shares were derived (§ disclosed choice).
pub basis: Basis,
shares: Vec<TenantShare>,
/// Facility energy that no tenant caused: idle capacity, cooling headroom,
/// the control plane, failed work. REQUIRED — see the module docs.
pub residual_uj: u64,
}
impl Allocation {
/// Build an allocation, enforcing conservation exactly.
///
/// Rejects rather than clamps. An over-allocation means the model is wrong,
/// and silently trimming it would hide the only signal that says so.
pub fn new(
total: FacilityTotal,
basis: Basis,
shares: Vec<TenantShare>,
residual_uj: u64,
) -> Result<Self, AllocationError> {
if total.period_end_us < total.period_start_us {
return Err(AllocationError::BadPeriod);
}
if shares.is_empty() {
return Err(AllocationError::NoShares);
}
for (i, s) in shares.iter().enumerate() {
if shares[..i].iter().any(|o| o.tenant == s.tenant) {
return Err(AllocationError::DuplicateTenant(s.tenant.clone()));
}
}
// u128 so an overflowing set of shares is reported as over-allocation
// rather than wrapping into a total that appears to conserve.
let sum: u128 = shares.iter().map(|s| u128::from(s.uj)).sum();
if sum > u128::from(total.facility_uj) {
return Err(AllocationError::OverAllocated { shares: sum, total: total.facility_uj });
}
let got = sum + u128::from(residual_uj);
if got != u128::from(total.facility_uj) {
return Err(AllocationError::NotConserved { got, total: total.facility_uj });
}
Ok(Allocation { total, basis, shares, residual_uj })
}
/// Allocate proportionally to a per-tenant weight, assigning every
/// microjoule the weights do not account for to the residual.
///
/// Integer division loses microjoules; they go to the residual rather than
/// being spread by a rounding rule, because the residual is the honest home
/// for energy no tenant demonstrably caused. `allocatable_uj` is the portion
/// of the facility total the operator considers tenant-attributable at all —
/// the rest is residual by construction.
pub fn proportional(
total: FacilityTotal,
basis: Basis,
weights: &[(String, u64)],
allocatable_uj: u64,
) -> Result<Self, AllocationError> {
if weights.is_empty() {
return Err(AllocationError::NoShares);
}
if allocatable_uj > total.facility_uj {
return Err(AllocationError::OverAllocated {
shares: u128::from(allocatable_uj),
total: total.facility_uj,
});
}
let wsum: u128 = weights.iter().map(|(_, w)| u128::from(*w)).sum();
let shares: Vec<TenantShare> = if wsum == 0 {
// No weight anywhere: nothing is attributable, and saying so is
// better than inventing an even split.
weights.iter().map(|(t, _)| TenantShare { tenant: t.clone(), uj: 0 }).collect()
} else {
weights
.iter()
.map(|(t, w)| TenantShare {
tenant: t.clone(),
uj: ((u128::from(allocatable_uj) * u128::from(*w)) / wsum) as u64,
})
.collect()
};
let assigned: u128 = shares.iter().map(|s| u128::from(s.uj)).sum();
let residual = u128::from(total.facility_uj) - assigned;
Allocation::new(total, basis, shares, residual as u64)
}
/// The allocated shares, in the order supplied.
pub fn shares(&self) -> &[TenantShare] {
&self.shares
}
/// The fraction of the facility nobody was charged for, in parts per
/// million. A high value is not a defect to be hidden — it is the operator
/// telling the truth about idle and overhead.
pub fn residual_ppm(&self) -> u64 {
if self.total.facility_uj == 0 {
return 0;
}
((u128::from(self.residual_uj) * 1_000_000) / u128::from(self.total.facility_uj)) as u64
}
/// The Merkle sum tree's leaves, in the order shares were supplied.
fn leaves(&self) -> Vec<SumNode> {
self.shares
.iter()
.map(|s| SumNode { hash: s.leaf_hash(), sum_uj: s.uj })
.collect()
}
/// The root commitment over all shares.
pub fn root(&self) -> SumNode {
let mut level = self.leaves();
while level.len() > 1 {
level = level
.chunks(2)
.map(|c| if c.len() == 2 { combine(&c[0], &c[1]) } else { c[0] })
.collect();
}
level[0]
}
/// An inclusion proof for one tenant: their own share plus the sibling
/// `(hash, sum)` pairs along the path to the root.
pub fn proof_for(&self, tenant: &str) -> Option<ShareProof> {
let idx = self.shares.iter().position(|s| s.tenant == tenant)?;
let mut level = self.leaves();
let mut i = idx;
let mut path = Vec::new();
while level.len() > 1 {
let sib = if i % 2 == 0 { i + 1 } else { i - 1 };
if sib < level.len() {
path.push((i % 2 == 1, level[sib]));
}
level = level
.chunks(2)
.map(|c| if c.len() == 2 { combine(&c[0], &c[1]) } else { c[0] })
.collect();
i /= 2;
}
Some(ShareProof {
share: self.shares[idx].clone(),
path,
residual_uj: self.residual_uj,
facility_uj: self.total.facility_uj,
})
}
}
/// What one tenant is handed: enough to check their own figure closes against
/// the facility total, and no co-tenant's individual share.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ShareProof {
/// The tenant's own share.
pub share: TenantShare,
/// `(sibling_is_left, sibling)` from leaf to root.
pub path: Vec<(bool, SumNode)>,
/// The allocation's declared residual.
pub residual_uj: u64,
/// The facility total the whole allocation must close against.
pub facility_uj: u64,
}
impl ShareProof {
/// Recompute the root from this tenant's own leaf, and check the whole
/// allocation closes.
///
/// Passing establishes two things together: this share is committed in the
/// tree whose root the operator signed, **and** that tree's total plus the
/// residual is exactly the facility figure. Either alone is not enough — a
/// share can be genuinely included in a tree that does not add up.
pub fn verify(&self, signed_root: &SumNode) -> bool {
let mut node = SumNode { hash: self.share.leaf_hash(), sum_uj: self.share.uj };
for (sibling_is_left, sib) in &self.path {
node = if *sibling_is_left { combine(sib, &node) } else { combine(&node, sib) };
}
if node != *signed_root {
return false;
}
u128::from(node.sum_uj) + u128::from(self.residual_uj) == u128::from(self.facility_uj)
}
/// How many co-tenant subtree aggregates this proof reveals. Stated so a
/// tenant knows what they learned, and an operator knows what they gave
/// away — roughly `log2(n)` partial sums, never an individual share.
pub fn aggregates_revealed(&self) -> usize {
self.path.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn total(uj: u64) -> FacilityTotal {
FacilityTotal {
facility_id: "dc-1".into(),
period_start_us: 0,
period_end_us: 3_600_000_000,
facility_uj: uj,
}
}
fn s(t: &str, uj: u64) -> TenantShare {
TenantShare { tenant: t.into(), uj }
}
/// The invariant the whole module exists for, and the reason it is integer:
/// it holds exactly or the allocation does not exist.
#[test]
fn an_allocation_must_close_exactly() {
let a = Allocation::new(
total(1_000),
Basis::MeasuredEnergy,
vec![s("acme", 400), s("globex", 350)],
250,
)
.expect("400 + 350 + 250 = 1000");
assert_eq!(a.residual_ppm(), 250_000);
// One microjoule short: rejected, not rounded away.
assert!(matches!(
Allocation::new(total(1_000), Basis::MeasuredEnergy, vec![s("acme", 400)], 599),
Err(AllocationError::NotConserved { got: 999, total: 1_000 })
));
// Charging tenants for more than the building drew is a broken model,
// and is reported as one rather than clamped.
assert!(matches!(
Allocation::new(total(1_000), Basis::MeasuredEnergy, vec![s("acme", 900), s("globex", 900)], 0),
Err(AllocationError::OverAllocated { .. })
));
}
/// Overflowing shares must not wrap into a total that appears to conserve.
#[test]
fn overflowing_shares_are_over_allocation_not_a_wrap() {
let r = Allocation::new(
total(1_000),
Basis::MeasuredEnergy,
vec![s("a", u64::MAX), s("b", u64::MAX)],
0,
);
assert!(matches!(r, Err(AllocationError::OverAllocated { .. })));
}
/// Integer division loses microjoules. They go to the residual, because the
/// residual is the honest home for energy no tenant demonstrably caused —
/// and conservation still closes exactly.
#[test]
fn proportional_sends_its_rounding_loss_to_the_residual() {
// 1000 allocatable over weights 1:1:1 divides unevenly.
let a = Allocation::proportional(
total(1_000),
Basis::ComputeSeconds,
&[("a".into(), 1), ("b".into(), 1), ("c".into(), 1)],
1_000,
)
.unwrap();
let sum: u64 = a.shares().iter().map(|x| x.uj).sum();
assert_eq!(sum, 999, "333 each");
assert_eq!(a.residual_uj, 1, "the lost microjoule is named, not spread");
assert_eq!(sum + a.residual_uj, 1_000);
}
/// Only part of a facility is tenant-attributable; the rest is residual by
/// construction rather than by omission.
#[test]
fn unallocatable_capacity_lands_in_the_residual() {
let a = Allocation::proportional(
total(10_000),
Basis::ReservedCapacity,
&[("a".into(), 3), ("b".into(), 1)],
8_000, // 20% of the facility is not tenant-attributable at all
)
.unwrap();
assert_eq!(a.shares()[0].uj, 6_000);
assert_eq!(a.shares()[1].uj, 2_000);
assert_eq!(a.residual_uj, 2_000);
assert_eq!(a.residual_ppm(), 200_000);
}
/// No weights anywhere means nothing is attributable. Saying so beats
/// inventing an even split that looks like a measurement.
#[test]
fn zero_weights_allocate_nothing() {
let a = Allocation::proportional(
total(500),
Basis::Declared,
&[("a".into(), 0), ("b".into(), 0)],
500,
)
.unwrap();
assert!(a.shares().iter().all(|x| x.uj == 0));
assert_eq!(a.residual_uj, 500);
}
/// The point of the sum tree: a tenant checks its own figure closes against
/// the signed facility total, without being shown any co-tenant's share.
#[test]
fn a_tenant_verifies_its_own_share_without_seeing_the_others() {
let a = Allocation::new(
total(10_000),
Basis::MeasuredEnergy,
vec![s("acme", 4_000), s("globex", 3_000), s("initech", 2_000), s("umbrella", 500)],
500,
)
.unwrap();
let root = a.root();
assert_eq!(root.sum_uj, 9_500);
for t in ["acme", "globex", "initech", "umbrella"] {
let p = a.proof_for(t).expect("tenant present");
assert!(p.verify(&root), "{t} must verify");
// 4 leaves -> 2 sibling aggregates on the path, never an individual
// co-tenant share.
assert_eq!(p.aggregates_revealed(), 2);
}
assert!(a.proof_for("nobody").is_none());
}
/// A share genuinely in the tree is still not enough: the tree must also add
/// up to the facility total. Both halves are checked together.
#[test]
fn inclusion_alone_does_not_satisfy_the_check() {
let a = Allocation::new(
total(1_000),
Basis::MeasuredEnergy,
vec![s("acme", 600), s("globex", 300)],
100,
)
.unwrap();
let root = a.root();
// Real inclusion proof, but the operator misstates the facility total.
let mut p = a.proof_for("acme").unwrap();
p.facility_uj = 2_000;
assert!(!p.verify(&root), "a tree that does not close must fail");
// Real total, but a tampered share no longer reconstructs the root.
let mut p = a.proof_for("acme").unwrap();
p.share.uj = 900;
assert!(!p.verify(&root), "an altered share must not verify");
// Restating the residual breaks conservation too.
let mut p = a.proof_for("globex").unwrap();
p.residual_uj = 0;
assert!(!p.verify(&root));
}
/// An odd tenant count must not break the tree.
#[test]
fn odd_leaf_counts_still_verify() {
for n in 1..=9u64 {
let shares: Vec<TenantShare> = (0..n).map(|i| s(&format!("t{i}"), 100)).collect();
let a = Allocation::new(total(100 * n + 7), Basis::MeasuredEnergy, shares, 7).unwrap();
let root = a.root();
assert_eq!(root.sum_uj, 100 * n);
for i in 0..n {
let p = a.proof_for(&format!("t{i}")).unwrap();
assert!(p.verify(&root), "n={n} tenant {i}");
}
}
}
/// A tenant cannot be charged twice under two entries.
#[test]
fn duplicate_tenants_are_rejected() {
assert!(matches!(
Allocation::new(total(200), Basis::MeasuredEnergy, vec![s("acme", 100), s("acme", 100)], 0),
Err(AllocationError::DuplicateTenant(_))
));
}
}

View file

@ -25,6 +25,8 @@
#![forbid(unsafe_code)]
#![warn(missing_docs)]
pub mod allocation;
pub mod rating;
pub mod account;
pub mod co2signal;
pub mod electricity_maps;

View file

@ -0,0 +1,414 @@
//! A facility rating receipt — a published band, bound to the allocation it
//! came from.
//!
//! # What this is for
//!
//! [`crate::allocation::Allocation`] divides a measured facility total among
//! tenants and commits the shares to a Merkle sum tree. A published rating —
//! the A–G band a facility advertises — is downstream of exactly that: it is a
//! claim about a number, derived from a set of workloads, over a period.
//!
//! Published on its own, a band is unfalsifiable. This receipt carries the
//! commitment to the share set the rating aggregates, so the tenant proof from
//! [`crate::allocation::Allocation::proof_for`] verifies against **the same
//! root the public rating cites**. A tenant can therefore confirm its own
//! consumption is inside the very set that produced the published label,
//! without seeing any co-tenant — which is the only thing that makes a facility
//! label mean anything to the customers it is derived from.
//!
//! # Assurance is not a field
//!
//! The temptation is a `verified: bool`, or an assurance level stored in the
//! artifact. That would be the same failure as the band itself: a claim about
//! evidence, asserted by the party under audit.
//!
//! So [`Assurance`] is **derived**, and derived from what the caller actually
//! checked. [`FacilityRating::assurance`] takes a [`SignatureStatus`] the
//! caller must supply, and the presence of an attestation on its own yields
//! [`Assurance::AttestedUnverified`] — never `Verified`. An unchecked signature
//! is a string; it becomes evidence only when someone checks it, and the type
//! makes that step impossible to skip silently.
//!
//! This crate deliberately performs no signature verification: there is no
//! signing dependency in this workspace, and inventing a `Verified` state it
//! cannot substantiate is precisely what the rest of this module refuses to do.
use serde::{Deserialize, Serialize};
use crate::allocation::{Allocation, ShareProof, SumNode};
/// The rating itself, recorded as data.
///
/// Deliberately not a dependency on whichever crate produced the band: a
/// receipt records *what was rated*, not *how*. Every field needed to re-derive
/// the letter travels with it, so a reader can check the band against the cited
/// scale instead of trusting the letter.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RatingRecord {
/// The scale that assigned the band, name and version. A band without this
/// is not comparable to any other band.
pub scale_id: String,
/// What was measured.
pub metric: String,
/// The unit of [`RatingRecord::value`].
pub unit: String,
/// The measured value the band was derived from.
pub value: u64,
/// The band earned, as its wire label (`"A"`..`"G"`).
pub band: String,
}
/// A verifier's attestation over a rating. Carried, never trusted on sight.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct VerifierAttestation {
/// Who attested.
pub verifier_id: String,
/// The signature algorithm, so a checker knows what it is checking.
pub alg: String,
/// The signature over [`FacilityRating::core_bytes`], hex.
pub sig_hex: String,
}
/// What the caller actually did about the attestation's signature.
///
/// Required rather than defaulted: every value here is a different claim, and
/// picking one silently on the caller's behalf is how "unchecked" becomes
/// "verified" in a report.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SignatureStatus {
/// Nobody checked it.
NotChecked,
/// Checked against the verifier's key, and it held.
Valid,
/// Checked, and it did not hold.
Invalid,
}
/// How much weight a rating can bear.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Assurance {
/// No attestation at all — the operator's own word about its own facility.
SelfDeclared,
/// An attestation is present but nobody has checked the signature. This is
/// **not** a middle rung between self-declared and verified; it is
/// self-declared with a string attached, and is named separately only so a
/// reader can tell that a check is available and was not run.
AttestedUnverified {
/// Who claims to have attested.
verifier_id: String,
},
/// An attestation was checked and held.
Verified {
/// Who attested.
verifier_id: String,
},
/// An attestation was checked and failed. Strictly worse than none: someone
/// tried to pass off a bad signature.
AttestationInvalid {
/// Who the failed attestation named.
verifier_id: String,
},
}
/// A published facility rating, bound to the allocation behind it.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FacilityRating {
/// Which facility.
pub facility_id: String,
/// Start of the rated period (microseconds since the epoch).
pub period_start_us: u64,
/// End of the rated period.
pub period_end_us: u64,
/// The measured facility draw the rating covers.
pub facility_uj: u64,
/// Energy attributed to no tenant.
pub residual_uj: u64,
/// How many tenants the allocation divided among.
pub tenant_count: u32,
/// The basis the shares were derived on, carried so the rating discloses it.
pub basis: String,
/// Commitment to the per-tenant share set — the same root a tenant's
/// [`ShareProof`] verifies against.
pub allocation_root: SumNode,
/// The band and everything needed to re-derive it.
pub rating: RatingRecord,
/// A verifier's attestation, if one was obtained.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attestation: Option<VerifierAttestation>,
}
impl FacilityRating {
/// Build a rating receipt over an allocation.
///
/// Every total is taken from the allocation rather than accepted from the
/// caller, so the published figures cannot drift from the set they commit
/// to.
pub fn from_allocation(alloc: &Allocation, rating: RatingRecord) -> Self {
FacilityRating {
facility_id: alloc.total.facility_id.clone(),
period_start_us: alloc.total.period_start_us,
period_end_us: alloc.total.period_end_us,
facility_uj: alloc.total.facility_uj,
residual_uj: alloc.residual_uj,
tenant_count: alloc.shares().len() as u32,
basis: alloc.basis.label().to_string(),
allocation_root: alloc.root(),
rating,
attestation: None,
}
}
/// Attach a verifier's attestation. Attaching is not verifying — see
/// [`FacilityRating::assurance`].
pub fn attested_by(mut self, attestation: VerifierAttestation) -> Self {
self.attestation = Some(attestation);
self
}
/// The bytes a verifier signs: every field that makes the rating a claim.
///
/// The attestation is excluded, since it signs this. Fields are
/// length-prefixed rather than concatenated, so two different receipts
/// cannot produce the same preimage by shifting bytes across a boundary.
pub fn core_bytes(&self) -> Vec<u8> {
let mut o = b"eoc:facility-rating\x01".to_vec();
let field = |b: &[u8], out: &mut Vec<u8>| {
out.extend_from_slice(&(b.len() as u64).to_be_bytes());
out.extend_from_slice(b);
};
field(self.facility_id.as_bytes(), &mut o);
o.extend_from_slice(&self.period_start_us.to_be_bytes());
o.extend_from_slice(&self.period_end_us.to_be_bytes());
o.extend_from_slice(&self.facility_uj.to_be_bytes());
o.extend_from_slice(&self.residual_uj.to_be_bytes());
o.extend_from_slice(&self.tenant_count.to_be_bytes());
field(self.basis.as_bytes(), &mut o);
o.extend_from_slice(&self.allocation_root.hash);
o.extend_from_slice(&self.allocation_root.sum_uj.to_be_bytes());
field(self.rating.scale_id.as_bytes(), &mut o);
field(self.rating.metric.as_bytes(), &mut o);
field(self.rating.unit.as_bytes(), &mut o);
o.extend_from_slice(&self.rating.value.to_be_bytes());
field(self.rating.band.as_bytes(), &mut o);
o
}
/// Derive how much weight this rating can bear, from what was actually
/// checked.
///
/// There is no `assurance` field to read: the level is a function of the
/// evidence present *and* the check the caller performed. A caller that has
/// not checked passes [`SignatureStatus::NotChecked`] and gets
/// [`Assurance::AttestedUnverified`], which is the truth about what it
/// knows.
pub fn assurance(&self, sig: SignatureStatus) -> Assurance {
match (&self.attestation, sig) {
(None, _) => Assurance::SelfDeclared,
(Some(a), SignatureStatus::NotChecked) => {
Assurance::AttestedUnverified { verifier_id: a.verifier_id.clone() }
}
(Some(a), SignatureStatus::Valid) => {
Assurance::Verified { verifier_id: a.verifier_id.clone() }
}
(Some(a), SignatureStatus::Invalid) => {
Assurance::AttestationInvalid { verifier_id: a.verifier_id.clone() }
}
}
}
/// Re-check that the receipt's own figures close: committed shares plus
/// residual equal the facility total.
///
/// The allocation enforced this at construction, but a receipt travels
/// separately and its fields are public, so a reader checks rather than
/// assumes.
pub fn conserves(&self) -> bool {
u128::from(self.allocation_root.sum_uj) + u128::from(self.residual_uj)
== u128::from(self.facility_uj)
}
/// Whether a tenant's proof belongs to the set this rating was derived from.
///
/// This is the join that makes a published band answerable to the customers
/// underneath it: the tenant checks its own share against the very root the
/// public rating cites.
pub fn admits(&self, proof: &ShareProof) -> bool {
proof.facility_uj == self.facility_uj
&& proof.residual_uj == self.residual_uj
&& proof.verify(&self.allocation_root)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::allocation::{Basis, FacilityTotal, TenantShare};
fn alloc() -> Allocation {
Allocation::new(
FacilityTotal {
facility_id: "dc-1".into(),
period_start_us: 0,
period_end_us: 3_600_000_000,
facility_uj: 10_000,
},
Basis::MeasuredEnergy,
vec![
TenantShare { tenant: "acme".into(), uj: 4_000 },
TenantShare { tenant: "globex".into(), uj: 3_000 },
TenantShare { tenant: "initech".into(), uj: 2_000 },
],
1_000,
)
.unwrap()
}
fn record() -> RatingRecord {
RatingRecord {
scale_id: "example.pue/1".into(),
metric: "pue".into(),
unit: "milli_pue".into(),
value: 1_180,
band: "B".into(),
}
}
fn att() -> VerifierAttestation {
VerifierAttestation {
verifier_id: "did:example:auditor".into(),
alg: "ed25519".into(),
sig_hex: "00".repeat(64),
}
}
/// The join the whole module exists for: a tenant confirms its own share is
/// inside the very set that produced the published band, without seeing any
/// co-tenant.
#[test]
fn a_tenant_can_place_itself_inside_the_published_rating() {
let a = alloc();
let r = FacilityRating::from_allocation(&a, record());
assert!(r.conserves());
for t in ["acme", "globex", "initech"] {
let p = a.proof_for(t).unwrap();
assert!(r.admits(&p), "{t} must be admitted by the published rating");
// Only sibling aggregates are learned, never an individual share,
// and never more than the tree is deep. With an odd leaf count a
// promoted node has no sibling at that level, so the count varies
// between tenants — the guarantee is the bound, not a fixed number.
assert!(
p.aggregates_revealed() <= 2,
"{t} revealed {} aggregates, more than ceil(log2(3))",
p.aggregates_revealed()
);
}
}
/// A proof from a different facility period must not be admitted, even
/// though it is internally valid.
#[test]
fn a_valid_proof_from_another_allocation_is_not_admitted() {
let r = FacilityRating::from_allocation(&alloc(), record());
let other = Allocation::new(
FacilityTotal {
facility_id: "dc-1".into(),
period_start_us: 3_600_000_000,
period_end_us: 7_200_000_000,
facility_uj: 10_000,
},
Basis::MeasuredEnergy,
vec![TenantShare { tenant: "acme".into(), uj: 9_000 }],
1_000,
)
.unwrap();
let p = other.proof_for("acme").unwrap();
assert!(p.verify(&other.root()), "the proof is valid on its own allocation");
assert!(!r.admits(&p), "but it does not belong to this rating");
}
/// Assurance is derived from what was checked, never from a stored field.
/// Presence of a signature is not verification.
#[test]
fn assurance_reflects_the_check_that_was_actually_run() {
let plain = FacilityRating::from_allocation(&alloc(), record());
// No attestation: the operator's own word, whatever the caller claims.
assert_eq!(plain.assurance(SignatureStatus::NotChecked), Assurance::SelfDeclared);
assert_eq!(plain.assurance(SignatureStatus::Valid), Assurance::SelfDeclared);
let signed = FacilityRating::from_allocation(&alloc(), record()).attested_by(att());
// The dangerous default, named honestly rather than promoted.
assert_eq!(
signed.assurance(SignatureStatus::NotChecked),
Assurance::AttestedUnverified { verifier_id: "did:example:auditor".into() }
);
assert_eq!(
signed.assurance(SignatureStatus::Valid),
Assurance::Verified { verifier_id: "did:example:auditor".into() }
);
// A failed check is strictly worse than no attestation at all.
assert_eq!(
signed.assurance(SignatureStatus::Invalid),
Assurance::AttestationInvalid { verifier_id: "did:example:auditor".into() }
);
}
/// Every field that makes the rating a claim is inside the signed preimage,
/// so a verifier's signature cannot be carried over to a different claim.
#[test]
fn the_signed_core_covers_every_load_bearing_field() {
let base = FacilityRating::from_allocation(&alloc(), record());
let core = base.core_bytes();
let mut t = base.clone();
t.rating.band = "A".into();
assert_ne!(core, t.core_bytes(), "the band must be signed");
let mut t = base.clone();
t.rating.value = 1_179;
assert_ne!(core, t.core_bytes(), "the measured value must be signed");
let mut t = base.clone();
t.rating.scale_id = "example.pue/2".into();
assert_ne!(core, t.core_bytes(), "the scale must be signed");
let mut t = base.clone();
t.residual_uj += 1;
assert_ne!(core, t.core_bytes(), "the residual must be signed");
let mut t = base.clone();
t.allocation_root.sum_uj += 1;
assert_ne!(core, t.core_bytes(), "the commitment must be signed");
let mut t = base.clone();
t.basis = "reserved_capacity".into();
assert_ne!(core, t.core_bytes(), "the basis must be signed");
// Attaching an attestation does not change what it signs.
assert_eq!(core, base.clone().attested_by(att()).core_bytes());
}
/// Length-prefixing makes the preimage injective: shifting a character
/// across a field boundary must not produce the same bytes.
#[test]
fn the_preimage_is_injective_across_field_boundaries() {
let mut a = FacilityRating::from_allocation(&alloc(), record());
a.facility_id = "dc".into();
a.basis = "1measured_energy".into();
let mut b = FacilityRating::from_allocation(&alloc(), record());
b.facility_id = "dc1".into();
b.basis = "measured_energy".into();
assert_ne!(a.core_bytes(), b.core_bytes());
}
/// A receipt travels separately from the allocation, so a reader re-checks
/// conservation rather than assuming it.
#[test]
fn a_receipt_that_does_not_close_is_detectable() {
let mut r = FacilityRating::from_allocation(&alloc(), record());
assert!(r.conserves());
r.residual_uj = 999;
assert!(!r.conserves(), "9_000 + 999 != 10_000");
}
}

View file

@ -20,7 +20,10 @@ jsonschema = { version = "0.46", default-features = false }
tokio = { workspace = true, features = ["macros", "rt", "sync", "fs", "process", "io-util", "time"] }
async-trait.workspace = true
futures = "0.3"
# reqwest 0.13 moved RequestBuilder::query behind the "query" feature.
# `query` is a feature in reqwest 0.13 (it was unconditional in 0.12), and this
# crate builds four search adapters with `.query(..)`. With
# `default-features = false` the method is simply not compiled, so the crate has
# not built since the 0.13 bump.
reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "query"] }
thiserror.workspace = true
tracing = "0.1"