The doc comment on Provenance::HwShunt still read "RAPL/SMC/shunt", with no liveness condition. The map spec in the previous commit now says a counter counts only while it is demonstrably live (receipt.md §2). This brings the doc comment into line. Doc comment only. Co-Authored-By: Claude Opus 5.5 <[email protected]>
219 lines
8.7 KiB
Rust
219 lines
8.7 KiB
Rust
//! The Map receipt (SPEC §6).
|
|
//!
|
|
//! A [`Receipt`] is the signed, two-part energy record (`measured + estimated`,
|
|
//! integer microjoules) emitted for every resolve/decode of a tile. Every
|
|
//! reading carries a [`Provenance`] tag; a `measured` figure MUST come from
|
|
//! hardware. Receipts seal as **JCR-1** envelopes — the family's shared
|
|
//! signed-receipt format (canonical det-CBOR + COSE_Sign1/EdDSA) — so a Map
|
|
//! receipt verifies with the same tooling as a Joule Code or Sandbox receipt.
|
|
|
|
use ed25519_dalek::SigningKey;
|
|
use map_schema::{CellKey, ContentId, Tier};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
pub mod jcr1;
|
|
|
|
/// Reference estimator: microjoules per resolved tile by cascade tier
|
|
/// (non-normative, SPEC §6). The standard is that the figure is reported and
|
|
/// honestly tagged — not the constant. Energy rises with fidelity.
|
|
pub const UJ_BY_TIER: [(Tier, u64); 5] = [
|
|
(Tier::Cache, 1), // ~picojoules: a local store hit
|
|
(Tier::Base, 1_000), // ~microjoules: terrain/vector floor
|
|
(Tier::Field, 200_000), // ~sub-millijoules: a raster/field tile
|
|
(Tier::Feature, 5_000_000), // ~millijoules: vector features + entities
|
|
(Tier::Photoreal, 2_000_000_000), // ~joules: a splat tile
|
|
];
|
|
|
|
/// How honest an energy reading is (SPEC §6).
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum Provenance {
|
|
/// Read from a hardware energy interface: a shunt, or a vendor energy
|
|
/// counter (RAPL, NVML, Apple SMC/IOReport) while that counter is
|
|
/// demonstrably live for the domain the figure claims (see
|
|
/// `spec/receipt.md` §2). A counter that is present but not live is not a
|
|
/// hardware reading. Requires a present `measured_uj`.
|
|
HwShunt,
|
|
/// Computed from a calibrated per-operation model. `measured_uj` may be absent.
|
|
ModelBased,
|
|
/// A coarse constant estimator. `measured_uj` MUST be absent.
|
|
Estimator,
|
|
}
|
|
|
|
impl Provenance {
|
|
/// The JCR-1 wire label, matching the family's receipt encoding.
|
|
pub fn jcr1_label(self) -> &'static str {
|
|
match self {
|
|
Provenance::HwShunt => "HwShunt",
|
|
Provenance::ModelBased => "ModelBased",
|
|
Provenance::Estimator => "Estimator",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A two-part energy record, all integer microjoules (SPEC §6).
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct Energy {
|
|
/// Present ONLY when obtained from a hardware interface.
|
|
pub measured_uj: Option<u64>,
|
|
/// Always present.
|
|
pub estimated_uj: u64,
|
|
pub provenance: Provenance,
|
|
}
|
|
|
|
impl Energy {
|
|
/// The estimator of last resort: the reference per-tier constant. Tags the
|
|
/// reading `Estimator` and leaves `measured_uj` absent.
|
|
pub fn estimate_tier(tier: Tier) -> Self {
|
|
let estimated_uj = UJ_BY_TIER
|
|
.iter()
|
|
.find(|(t, _)| *t == tier)
|
|
.map(|(_, uj)| *uj)
|
|
.unwrap_or(0);
|
|
Energy { measured_uj: None, estimated_uj, provenance: Provenance::Estimator }
|
|
}
|
|
|
|
/// Enforce the provenance ↔ measured coupling (SPEC §6): `HwShunt` requires
|
|
/// `measured_uj`; `Estimator` forbids it.
|
|
pub fn check_provenance(&self) -> Result<(), ReceiptError> {
|
|
match (self.provenance, self.measured_uj) {
|
|
(Provenance::HwShunt, None) => Err(ReceiptError::HwShuntRequiresMeasured),
|
|
(Provenance::Estimator, Some(_)) => Err(ReceiptError::EstimatorMustNotCarryMeasured),
|
|
_ => Ok(()),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Errors from building, sealing, or verifying a receipt.
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum ReceiptError {
|
|
#[error("HwShunt provenance requires a measured figure")]
|
|
HwShuntRequiresMeasured,
|
|
#[error("Estimator provenance must not carry a measured figure")]
|
|
EstimatorMustNotCarryMeasured,
|
|
#[error("receipt signature did not verify")]
|
|
BadSignature,
|
|
#[error("envelope payload does not match receipt")]
|
|
PayloadMismatch,
|
|
#[error(transparent)]
|
|
Jcr1(#[from] jcr1::Jcr1Error),
|
|
}
|
|
|
|
/// A signed energy receipt for one tile resolve (SPEC §6).
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct Receipt {
|
|
/// The cell the request was for.
|
|
pub cell: CellKey,
|
|
/// The content id of the tile that closed the request.
|
|
pub content_id: ContentId,
|
|
/// The cascade tier that closed the request.
|
|
pub tier: Tier,
|
|
pub energy: Energy,
|
|
pub sequence: u64,
|
|
pub nonce: [u8; 8],
|
|
}
|
|
|
|
impl Receipt {
|
|
/// Project to JCR-1 wire JSON — the `map/1` profile of the family's receipt
|
|
/// format (SPEC §6). The cell key is rendered as its content id so the
|
|
/// signed core stays a flat uint/text map. Null fields are dropped.
|
|
pub fn to_jcr1_wire(&self, steward: &str) -> Result<serde_json::Value, ReceiptError> {
|
|
use serde_json::json;
|
|
let cell_id = ContentId::of(&self.cell)
|
|
.map_err(|e| jcr1::Jcr1Error::Envelope(e.to_string()))?;
|
|
let mut obj = serde_json::Map::new();
|
|
obj.insert("map_receipt".into(), json!("map/1"));
|
|
obj.insert("cell_id".into(), json!(cell_id.to_hex()));
|
|
obj.insert("content_id".into(), json!(self.content_id.to_hex()));
|
|
obj.insert("tier".into(), json!(self.tier.wire_tag()));
|
|
obj.insert("estimated_uj".into(), json!(self.energy.estimated_uj));
|
|
if let Some(m) = self.energy.measured_uj {
|
|
obj.insert("measured_uj".into(), json!(m));
|
|
}
|
|
obj.insert("energy_provenance".into(), json!(self.energy.provenance.jcr1_label()));
|
|
obj.insert("sequence".into(), json!(self.sequence));
|
|
obj.insert("nonce".into(), json!(hex::encode(self.nonce)));
|
|
obj.insert("steward".into(), json!(steward));
|
|
Ok(serde_json::Value::Object(obj))
|
|
}
|
|
|
|
/// The canonical signed-core bytes (JCR-1 det-CBOR).
|
|
pub fn signed_core(&self, steward: &str) -> Result<Vec<u8>, ReceiptError> {
|
|
self.energy.check_provenance()?;
|
|
Ok(jcr1::signed_core_bytes(&self.to_jcr1_wire(steward)?)?)
|
|
}
|
|
|
|
/// Seal into a JCR-1 COSE_Sign1 envelope. Validates the provenance coupling
|
|
/// first, so a dishonest receipt cannot be sealed.
|
|
pub fn seal(&self, key: &SigningKey, steward: &str) -> Result<Vec<u8>, ReceiptError> {
|
|
let core = self.signed_core(steward)?;
|
|
Ok(jcr1::cose_sign1_envelope(&core, key, steward))
|
|
}
|
|
|
|
/// Verify a JCR-1 envelope against a resolver key and confirm its payload is
|
|
/// exactly this receipt's canonical signed core.
|
|
pub fn verify_envelope(
|
|
&self,
|
|
envelope: &[u8],
|
|
steward: &str,
|
|
endpoint: &[u8; 32],
|
|
) -> Result<(), ReceiptError> {
|
|
let payload = jcr1::verify_cose_sign1(envelope, steward, endpoint)?;
|
|
if payload != self.signed_core(steward)? {
|
|
return Err(ReceiptError::PayloadMismatch);
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use ed25519_dalek::SigningKey;
|
|
use map_schema::{AltBand, Lod, S2CellId, TimeBucket};
|
|
|
|
fn cell() -> CellKey {
|
|
CellKey { s2: S2CellId(0x42), alt: AltBand(0), time: TimeBucket::ATEMPORAL, lod: Lod(0) }
|
|
}
|
|
|
|
fn rcpt(energy: Energy) -> Receipt {
|
|
Receipt {
|
|
cell: cell(),
|
|
content_id: ContentId::of_bytes(b"tile"),
|
|
tier: Tier::Photoreal,
|
|
energy,
|
|
sequence: 1,
|
|
nonce: [0, 0, 0, 0, 0, 0, 0, 1],
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn estimate_orders_by_tier() {
|
|
assert!(Energy::estimate_tier(Tier::Cache).estimated_uj < Energy::estimate_tier(Tier::Base).estimated_uj);
|
|
assert!(Energy::estimate_tier(Tier::Feature).estimated_uj < Energy::estimate_tier(Tier::Photoreal).estimated_uj);
|
|
}
|
|
|
|
#[test]
|
|
fn estimator_seal_verify_roundtrips() {
|
|
let sk = SigningKey::from_bytes(&[9u8; 32]);
|
|
let steward = "did:example:resolver1";
|
|
let r = rcpt(Energy::estimate_tier(Tier::Photoreal));
|
|
let env = r.seal(&sk, steward).unwrap();
|
|
assert!(r.verify_envelope(&env, steward, &sk.verifying_key().to_bytes()).is_ok());
|
|
// A different steward must not verify.
|
|
assert!(r.verify_envelope(&env, "did:example:other", &sk.verifying_key().to_bytes()).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn hwshunt_without_measured_is_rejected() {
|
|
let sk = SigningKey::from_bytes(&[9u8; 32]);
|
|
let r = rcpt(Energy { measured_uj: None, estimated_uj: 10, provenance: Provenance::HwShunt });
|
|
assert!(matches!(r.seal(&sk, "s"), Err(ReceiptError::HwShuntRequiresMeasured)));
|
|
}
|
|
|
|
#[test]
|
|
fn estimator_with_measured_is_rejected() {
|
|
let sk = SigningKey::from_bytes(&[9u8; 32]);
|
|
let r = rcpt(Energy { measured_uj: Some(10), estimated_uj: 10, provenance: Provenance::Estimator });
|
|
assert!(matches!(r.seal(&sk, "s"), Err(ReceiptError::EstimatorMustNotCarryMeasured)));
|
|
}
|
|
}
|