Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
//! Tests pinning the `ConsequenceHost` interface identity and wire forms.

#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]

use super::*;

#[test]
fn the_interface_is_its_own_not_the_desktop_or_site_memory_one() {
assert_ne!(INTERFACE, crate::names::INTERFACE);
assert_ne!(INTERFACE, crate::site_memory::INTERFACE);
assert_ne!(OBJECT_PATH, crate::site_memory::OBJECT_PATH);
assert_eq!(METHODS, ["Review"]);
}

#[test]
fn a_review_request_keeps_its_wire_form() {
let request: ReviewActionRequest = serde_json::from_value(serde_json::json!({
"task": "t1", "action": "pay now", "target": "Pay", "kind": "payment"
}))
.unwrap();
assert_eq!(request.kind, ConsequenceKind::Payment);
let json = serde_json::to_value(&request).unwrap();
assert_eq!(json["kind"], "payment");
let irreversible = serde_json::to_value(ConsequenceKind::Irreversible).unwrap();
assert_eq!(irreversible, "irreversible");
}

#[test]
fn a_reason_is_optional_and_left_out_when_absent() {
let reply: ReviewActionResponse = serde_json::from_str(r#"{"allow":true}"#).unwrap();
assert_eq!(reply.reason, None);
let json = serde_json::to_value(&reply).unwrap();
assert!(json.get("reason").is_none());
}
74 changes: 74 additions & 0 deletions crates/tinycomputer-bus/src/consequence_host/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
//! The `ConsequenceHost` interface: the host callback that lets a host's
//! policy and approval path rule on an irreversible or paying action before
//! the module carries it out.
//!
//! The module classifies controls itself (`tinycomputer-core::safety`): a
//! control that cannot be taken back, or that moves money, is never pressed
//! on the model's say-so. A task pauses before it as `NeedsApproval`, and
//! answering `ContinueTask { approve: true }` is what lets it go ahead. With
//! this interface served, the module asks the host first and only honours
//! the approval when the host allows it, so the host's security policy, not
//! just a person's click, has the last word.
//!
//! One member, synchronous request/response over plain data:
//!
//! - [`methods::REVIEW`] takes a [`ReviewActionRequest`], returns a
//! [`ReviewActionResponse`].
//!
//! A host that does not serve the interface leaves the module's own approval
//! pause as the only gate. A host that serves it but answers an error, or
//! cannot be reached, denies: the action is declined, never assumed safe.

use serde::{Deserialize, Serialize};

/// The interface the host serves consequence review on.
pub const INTERFACE: &str = "ai.tinyhumans.tinycomputer.ConsequenceHost";

/// The object path the host serves [`INTERFACE`] at.
pub const OBJECT_PATH: &str = "/ai/tinyhumans/tinycomputer/ConsequenceHost";

/// One constant per member of [`INTERFACE`].
pub mod methods {
/// Rules on one action. Takes a [`crate::consequence_host::ReviewActionRequest`].
pub const REVIEW: &str = "Review";
}

/// Every member of [`INTERFACE`], in dispatch order.
pub const METHODS: [&str; 1] = [methods::REVIEW];

/// What an action commits to; the module's classification of its control.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConsequenceKind {
/// Cannot be taken back: a message sent, data deleted, a booking
/// confirmed.
Irreversible,
/// Money changes hands.
Payment,
}

/// `Review`: one action the module is about to carry out.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReviewActionRequest {
/// The task asking, as `TaskView.id` names it.
pub task: String,
/// What it would do, in plain words.
pub action: String,
/// The control it would press.
pub target: String,
/// What the module classified the action as.
pub kind: ConsequenceKind,
}

/// The reply to `Review`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReviewActionResponse {
/// Whether the host lets the action go ahead.
pub allow: bool,
/// Why not, in plain words, when `allow` is `false`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}

#[cfg(test)]
mod consequence_host_tests;
1 change: 1 addition & 0 deletions crates/tinycomputer-bus/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ pub mod apps;
pub mod browser;
pub mod catalogue;
pub mod clipboard;
pub mod consequence_host;
pub mod desktop_tool;
pub mod envelope;
mod error;
Expand Down
4 changes: 2 additions & 2 deletions crates/tinycomputer-bus/src/version/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
//! release workflow bumps, which tracks the shipped artifact.

/// The wire contract version this crate defines.
pub const CONTRACT_VERSION: (u32, u32) = (2, 18);
pub const CONTRACT_VERSION: (u32, u32) = (2, 19);

/// Returns whether a host holding [`CONTRACT_VERSION`] can bind to a module
/// reporting `module`.
Expand All @@ -22,7 +22,7 @@ pub const CONTRACT_VERSION: (u32, u32) = (2, 18);
/// ```
/// # use tinycomputer_bus::{is_compatible, CONTRACT_VERSION};
/// assert!(is_compatible(CONTRACT_VERSION));
/// assert!(is_compatible((2, 18)));
/// assert!(is_compatible((2, 19)));
/// assert!(!is_compatible((2, 11)));
/// assert!(!is_compatible((1, 8)));
/// ```
Expand Down
8 changes: 6 additions & 2 deletions crates/tinycomputer-bus/src/version/version_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use super::{CONTRACT_VERSION, binds, is_compatible};

#[test]
fn the_shipped_contract_version_is_pinned() {
assert_eq!(CONTRACT_VERSION, (2, 18));
assert_eq!(CONTRACT_VERSION, (2, 19));
}

#[test]
Expand All @@ -16,7 +16,7 @@ fn the_contract_binds_to_itself() {

#[test]
fn a_newer_minor_on_the_module_side_binds() {
assert!(is_compatible((2, 18)));
assert!(is_compatible((2, 19)));
assert!(is_compatible((2, 97)));
// 2.12 adds replayable GlobeRead and joined terminal GlobeShutdown.
// A 2.11 module does not implement those members.
Expand All @@ -32,6 +32,10 @@ fn a_newer_minor_on_the_module_side_binds() {
// 2.18 gives `Press` an optional `target`, which the module focuses and
// checks before the key goes: a 2.17 module would ignore the field and
// press the key wherever the focus is.
// 2.19 adds the ConsequenceHost host-callback interface (Review): a 2.19
// host serves it, which a 2.18 module does not call, so its approvals
// would never reach the host's policy.
assert!(!is_compatible((2, 18)));
assert!(!is_compatible((2, 17)));
assert!(!is_compatible((2, 16)));
assert!(!is_compatible((2, 15)));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
//! The host's `ConsequenceHost` rules on an approval before the task takes
//! its action, and a host that cannot answer never lets one through.

#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]

use std::sync::{Arc, Mutex};

use tinybus::Connection;
use tinycomputer_bus::agent::{ContinueTaskRequest, TaskId};

use super::*;

/// What a fake host answers.
#[derive(Debug, Clone)]
enum Answer {
Allow,
Deny(Option<&'static str>),
NotServed,
Broken,
}

#[derive(Debug, Clone)]
struct FakeHost {
answer: Answer,
seen: Arc<Mutex<Vec<ReviewActionRequest>>>,
}

impl FakeHost {
fn new(answer: Answer) -> Self {
Self {
answer,
seen: Arc::default(),
}
}
}

impl ConsequencePort for FakeHost {
fn review(&self, request: ReviewActionRequest) -> PortFuture<'_, ReviewActionResponse> {
self.seen.lock().unwrap().push(request);
Box::pin(async move {
match &self.answer {
Answer::Allow => Ok(ReviewActionResponse {
allow: true,
reason: None,
}),
Answer::Deny(reason) => Ok(ReviewActionResponse {
allow: false,
reason: reason.map(str::to_owned),
}),
Answer::NotServed => Err(PortError::NotServed),
Answer::Broken => Err(PortError::Failed("timed out".to_owned())),
}
})
}
}

fn approval(approve: Option<bool>) -> ContinueTaskRequest {
ContinueTaskRequest {
id: TaskId::new("t1"),
inputs: std::collections::BTreeMap::new(),
approve,
answer: None,
}
}

fn pending() -> (String, String) {
("send the message".to_owned(), "Send".to_owned())
}

async fn screened(answer: Answer, approve: Option<bool>) -> (Option<bool>, FakeHost) {
let host = FakeHost::new(answer);
let link = ConsequenceLink::new(Box::new(host.clone()));
let mut request = approval(approve);
link.screen(&mut request, Some(pending())).await;
(request.approve, host)
}

#[test]
fn a_payment_label_on_either_side_makes_the_action_a_payment() {
assert_eq!(kind_of("pay now", "Continue"), ConsequenceKind::Payment);
assert_eq!(
kind_of("press the button", "Place order"),
ConsequenceKind::Payment
);
assert_eq!(
kind_of("send the message", "Send"),
ConsequenceKind::Irreversible
);
}

#[test]
fn a_control_that_reads_as_reversible_still_counts_as_irreversible() {
assert_eq!(kind_of("continue", "Next"), ConsequenceKind::Irreversible);
}

#[tokio::test]
async fn an_allowed_approval_goes_through() {
let (approve, host) = screened(Answer::Allow, Some(true)).await;
assert_eq!(approve, Some(true));
let seen = host.seen.lock().unwrap();
assert_eq!(seen.len(), 1);
assert_eq!(seen[0].task, "t1");
assert_eq!(seen[0].target, "Send");
assert_eq!(seen[0].kind, ConsequenceKind::Irreversible);
}

#[tokio::test]
async fn a_denied_approval_becomes_a_decline() {
let (approve, _) = screened(Answer::Deny(Some("policy: no sends")), Some(true)).await;
assert_eq!(approve, Some(false));
let (approve, _) = screened(Answer::Deny(None), Some(true)).await;
assert_eq!(approve, Some(false));
}

#[tokio::test]
async fn a_host_that_fails_to_answer_declines_the_action() {
let (approve, _) = screened(Answer::Broken, Some(true)).await;
assert_eq!(approve, Some(false));
}

#[tokio::test]
async fn a_host_that_serves_nothing_leaves_the_approval_alone() {
let (approve, host) = screened(Answer::NotServed, Some(true)).await;
assert_eq!(approve, Some(true));
assert_eq!(host.seen.lock().unwrap().len(), 1);
}

#[tokio::test]
async fn a_decline_or_an_unanswered_pause_is_not_reviewed() {
for approve in [Some(false), None] {
let (after, host) = screened(Answer::Deny(None), approve).await;
assert_eq!(after, approve);
assert!(host.seen.lock().unwrap().is_empty());
}
}

#[tokio::test]
async fn an_approval_of_nothing_pending_is_not_reviewed() {
let host = FakeHost::new(Answer::Deny(None));
let link = ConsequenceLink::new(Box::new(host.clone()));
let mut request = approval(Some(true));
link.screen(&mut request, None).await;
assert_eq!(request.approve, Some(true));
assert!(host.seen.lock().unwrap().is_empty());
}

/// A host serving `ConsequenceHost` over a real bus.
#[derive(Clone)]
struct BusHost(FakeHost);

#[tinybus::interface(name = "ai.tinyhumans.tinycomputer.ConsequenceHost")]
impl BusHost {
async fn review(&self, request: ReviewActionRequest) -> tinybus::Result<ReviewActionResponse> {
self.0
.review(request)
.await
.map_err(|error| tinybus::Error::failed(format!("{error:?}")))
}
}

#[tokio::test]
async fn the_bus_port_reaches_the_hosts_interface() -> tinybus::Result<()> {
use tinybus::broker::Broker;
use tinybus::transport::memory::MemoryBus;

let bus = MemoryBus::new();
Broker::new().spawn(bus.clone());
let host_connection = Connection::connect(bus.connect().await?).await?;
let fake = FakeHost::new(Answer::Deny(Some("no")));
host_connection
.serve_at(OBJECT_PATH.try_into()?, BusHost(fake.clone()))
.await?;
host_connection.request_name(INTERFACE).await?;
let module = Connection::connect(bus.connect().await?).await?;

let link = ConsequenceLink::new(Box::new(BusPort::new(module)));
let verdict = link.review("t1", "pay now", "Pay").await;
assert_eq!(verdict, Verdict::Denied("no".to_owned()));
assert_eq!(fake.seen.lock().unwrap()[0].kind, ConsequenceKind::Payment);
Ok(())
}

#[tokio::test]
async fn the_bus_port_treats_a_host_that_serves_nothing_as_unreviewed() -> tinybus::Result<()> {
use tinybus::broker::Broker;
use tinybus::transport::memory::MemoryBus;

let bus = MemoryBus::new();
Broker::new().spawn(bus.clone());
let module = Connection::connect(bus.connect().await?).await?;
let link = ConsequenceLink::new(Box::new(BusPort::new(module)));
assert_eq!(link.review("t1", "send", "Send").await, Verdict::Unreviewed);
Ok(())
}
Loading
Loading