open-standards/wai/wai-web/src/video_ott.rs
Transaction Science 66ed977282 wai-web: seal the OTT demo's page-supplied joules as Estimator, not ModelBased
OttSession::new seals whatever non-zero joules_micro the page hands it
as ModelBased. Nothing in the session measured, modelled or calibrated
that figure, so labelling it a model claims better provenance than how
it was obtained (the family's ladder, JCP SPEC §5.1: a model earns
ModelBased by its calibration). It is now sealed Estimator; zero stays
the unmetered marker with no class.

New test a_page_supplied_figure_is_sealed_as_an_estimate. No vector
changes: the OTT session is a demo surface, nothing under a conformance
or vectors directory is produced by it, and reach/record keeps the
package of record as it was.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 10:11:54 -04:00

298 lines
10 KiB
Rust

//! Browser sink for the OTT workflow — `wai.video.manifest` + `wai.video.ssai`.
//!
//! The same `wai` crate a native packager runs, compiled to wasm32: the page
//! composes a session class, renders the real HLS manifest, seals the receipt
//! and verifies it in the tab. No second implementation, so what the browser
//! shows is what a native sink would produce.
//!
//! Three things the page can demonstrate that a dashboard cannot:
//!
//! - **The manifest is the receipt's subject.** `manifest_hash ==
//! hash(render(segments))` is recomputed here, in the tab.
//! - **Tamper-evidence.** [`OttSession::swap_ad`] substitutes a different
//! creative without re-signing — exactly the ad-fraud move — and `verify()`
//! flips to false.
//! - **The efficiency is amortised, not asserted.** Raising the viewers served
//! by one class drops `uj_per_viewer`, because the manifest is a pure function
//! of the class and is generated once for all of them.
use wasm_bindgen::prelude::*;
use wai::quantum_ops::GrantRef;
use wai::wai_video_manifest::{
render_hls, EnergyProvenance, ManifestReceipt, Segment, SegmentKind, SessionClass,
};
use wai::wai_video_ssai::{decide, pod_hash, AdCandidate, SsaiReceipt};
fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
/// A demo signing key — the page is a sink, not a licensor; a real deployment
/// holds its key in the packager.
fn demo_key() -> ed25519_dalek::SigningKey {
ed25519_dalek::SigningKey::from_bytes(&[42u8; 32])
}
/// Build a plausible break-bearing composition: `n_content` programme segments
/// of 6s with a single 4s ad spliced in after the first.
fn compose(n_content: u32, ad_id: &str) -> Vec<Segment> {
let mut segs = Vec::new();
let mut idx = 0u64;
for i in 0..n_content {
if i == 1 {
segs.push(Segment {
index: idx,
kind: SegmentKind::Ad,
uri: format!("ad/{ad_id}/0.ts"),
duration_ms: 4000,
ad_id: Some(ad_id.to_string()),
});
idx += 1;
}
segs.push(Segment {
index: idx,
kind: SegmentKind::Content,
uri: format!("seg{i}.ts"),
duration_ms: 6000,
ad_id: None,
});
idx += 1;
}
segs
}
/// An attested per-session manifest, live in the page.
#[wasm_bindgen]
pub struct OttSession {
receipt: ManifestReceipt,
/// The bytes the player was handed — kept as delivered, so a later tamper
/// of the composition is visible against them.
delivered: String,
}
#[wasm_bindgen]
impl OttSession {
/// Compose a session class, render its manifest and seal the receipt.
/// `blackouts` is a comma-separated set (order-independent by construction).
/// `joules_micro` is the page's figure: sealed `Estimator` when non-zero,
/// since nothing here measured or calibrated it, and unmetered at zero.
#[wasm_bindgen(constructor)]
#[allow(clippy::too_many_arguments)]
pub fn new(
content_id: &str,
entitlement: &str,
geo: &str,
blackouts: &str,
ad_pod_id: &str,
window: u64,
n_content: u32,
ad_id: &str,
viewers_served: u64,
joules_micro: u64,
) -> OttSession {
let class = SessionClass {
content_id: content_id.to_string(),
entitlement: entitlement.to_string(),
geo: geo.to_string(),
blackouts: blackouts
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)
.collect(),
ad_pod_id: ad_pod_id.to_string(),
window,
};
let segments = compose(n_content.max(1), ad_id);
let delivered = render_hls(&segments);
let grant = GrantRef {
grant_hash: [7u8; 32],
capability: "video.manifest".to_string(),
joule_ceiling_micro: u64::MAX,
funds_ceiling: None,
};
// The page supplies the figure; nothing here measured or modelled it,
// and no host calibrated it, so it is sealed as what it is: an
// estimate (`Estimator`), never `ModelBased`. Zero is the unmetered
// marker, with no class.
let prov = (joules_micro > 0).then_some(EnergyProvenance::Estimator);
let receipt = ManifestReceipt::seal(
&demo_key(),
"did:key:demo-packager",
class,
segments,
viewers_served.max(1),
joules_micro,
prov,
grant,
None,
);
OttSession { receipt, delivered }
}
/// The rendered HLS the player receives — ad breaks appear as real
/// `EXT-X-DISCONTINUITY` splices.
pub fn manifest(&self) -> String {
self.delivered.clone()
}
/// The manifest currently implied by the sealed composition. After a tamper
/// this diverges from [`OttSession::manifest`].
pub fn composed_manifest(&self) -> String {
render_hls(&self.receipt.segments)
}
pub fn manifest_hash_hex(&self) -> String {
hex(&self.receipt.manifest_hash)
}
/// The dedup key: every viewer of this class gets these identical bytes.
pub fn class_hash_hex(&self) -> String {
hex(&self.receipt.session_class_hash)
}
pub fn receipt_hash_hex(&self) -> String {
hex(&self.receipt.receipt_hash())
}
/// Signature + internal consistency, recomputed in the tab.
pub fn verify(&self) -> bool {
self.receipt.verify()
}
/// The independent check against the bytes actually delivered.
pub fn verify_delivered(&self) -> bool {
self.receipt.verify_render(self.delivered.as_bytes())
}
/// The monetisation audit, derived from the ad-bearing segments:
/// `[[break_index, ad_id, duration_ms], ...]` as JSON.
pub fn ads_json(&self) -> String {
let rows: Vec<String> = self
.receipt
.ad_insertions()
.iter()
.map(|(i, a, d)| format!("[{i},\"{a}\",{d}]"))
.collect();
format!("[{}]", rows.join(","))
}
pub fn segment_count(&self) -> u32 {
self.receipt.segments.len() as u32
}
pub fn viewers_served(&self) -> u64 {
self.receipt.viewers_served
}
/// Amortised microjoules per viewer, or `-1` when unmetered. This is the
/// figure a 1:1-unique generator cannot report below its per-viewer cost.
pub fn uj_per_viewer(&self) -> f64 {
self.receipt.uj_per_viewer().map_or(-1.0, |(v, _)| v)
}
/// Substitute a different creative into the break **without re-signing** —
/// the ad-fraud move. `verify()` flips to false, because the sealed
/// `manifest_hash` no longer matches what the composition renders.
pub fn swap_ad(&mut self, new_ad_id: &str) {
for s in self.receipt.segments.iter_mut() {
if s.ad_id.is_some() {
s.ad_id = Some(new_ad_id.to_string());
s.uri = format!("ad/{new_ad_id}/0.ts");
}
}
}
}
/// An attested ad break — the budget half of the story.
#[wasm_bindgen]
pub struct OttAdBreak {
receipt: SsaiReceipt,
}
#[wasm_bindgen]
impl OttAdBreak {
/// Run a deterministic decision over a three-ad pod against `budget_micros`,
/// mark the filled slots as played, and seal. Set `budget_micros` below the
/// resulting spend to watch a ceiling breach refuse to verify.
#[wasm_bindgen(constructor)]
pub fn new(avail_slots: u32, avail_ms: u64, budget_micros: u64, ceiling_micros: u64) -> OttAdBreak {
let pod = vec![
AdCandidate { ad_id: "ad-alpha".into(), creative_hash: [1u8; 32], duration_ms: 15000, price_micros: 40_000 },
AdCandidate { ad_id: "ad-beta".into(), creative_hash: [2u8; 32], duration_ms: 15000, price_micros: 30_000 },
AdCandidate { ad_id: "ad-gamma".into(), creative_hash: [3u8; 32], duration_ms: 30000, price_micros: 90_000 },
];
let mut imps = decide(&pod, avail_slots as u64, avail_ms, budget_micros);
for i in imps.iter_mut() {
i.quartiles_reached = 5; // played to completion
}
let grant = GrantRef {
grant_hash: [8u8; 32],
capability: "video.ssai".to_string(),
joule_ceiling_micro: u64::MAX,
funds_ceiling: Some(ceiling_micros),
};
let receipt = SsaiReceipt::seal(
&demo_key(),
"did:key:demo-ssai",
"avail-1",
[9u8; 32],
pod_hash(&pod),
imps,
3,
0,
None,
grant,
None,
);
OttAdBreak { receipt }
}
/// `false` when the spend exceeds the grant's ceiling — overspend cannot be
/// signed into a valid receipt.
pub fn verify(&self) -> bool {
self.receipt.verify()
}
pub fn budget_spent(&self) -> u64 {
self.receipt.budget_spent_micros
}
pub fn billable(&self) -> u32 {
self.receipt.billable_impressions() as u32
}
/// `[[slot, ad_id, price_micros, quartiles], ...]` as JSON.
pub fn slots_json(&self) -> String {
let rows: Vec<String> = self
.receipt
.impressions
.iter()
.map(|i| format!("[{},\"{}\",{},{}]", i.slot, i.ad_id, i.price_micros, i.quartiles_reached))
.collect();
format!("[{}]", rows.join(","))
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The page hands the session a joule figure that nothing here measured,
/// modelled or calibrated: a non-zero one is sealed `Estimator`, never
/// `ModelBased`, and zero is the unmetered marker, with no class. The
/// receipt verifies either way.
#[test]
fn a_page_supplied_figure_is_sealed_as_an_estimate() {
let session = |uj: u64| OttSession::new("film-1", "svod", "US", "", "pod-1", 60, 3, "ad-1", 4, uj);
let metered = session(8_000);
assert_eq!(metered.receipt.energy_provenance, Some(EnergyProvenance::Estimator));
assert_eq!(metered.uj_per_viewer(), 2_000.0);
assert!(metered.verify() && metered.verify_delivered());
let unmetered = session(0);
assert_eq!(unmetered.receipt.energy_provenance, None);
assert_eq!(unmetered.uj_per_viewer(), -1.0);
assert!(unmetered.verify());
}
}