use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::case::{CaseKey, CaseRef};
use crate::hash::{Digest, HashError, canonical_digest, derive_uuid};
use crate::ids::{AccountId, BatchId, CommandId, InteractionId, TurnId};
use crate::interaction::{ActionClass, InteractionKind};
use crate::turn::ActorContext;
use crate::understanding::ActId;
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum ResolutionChannel {
Click,
ModelInterpreted,
}
impl ResolutionChannel {
pub const ALL: [Self; 2] = [Self::Click, Self::ModelInterpreted];
#[must_use]
pub fn is_deterministic(self) -> bool {
matches!(self, Self::Click)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[non_exhaustive]
pub enum CommandOrigin {
DirectSafeUserAct {
evidence_digest: Digest,
},
ConfirmedInteraction {
interaction_id: InteractionId,
payload_hash: Digest,
interaction_kind: InteractionKind,
action_class: ActionClass,
channel: ResolutionChannel,
},
InternalPolicy {
policy_key: String,
},
ExternalCallback {
callback_id: String,
signature_verified: bool,
},
}
impl CommandOrigin {
#[must_use]
pub fn is_trusted(&self) -> bool {
match self {
Self::DirectSafeUserAct { .. } => false,
Self::ConfirmedInteraction {
action_class,
channel,
..
} => action_class.authorizes_commands() && channel.is_deterministic(),
Self::InternalPolicy { .. } => true,
Self::ExternalCallback {
signature_verified, ..
} => *signature_verified,
}
}
#[must_use]
fn confirming_kind(&self) -> Option<InteractionKind> {
match self {
Self::ConfirmedInteraction {
interaction_kind,
action_class,
channel,
..
} if action_class.authorizes_commands() && channel.is_deterministic() => {
Some(*interaction_kind)
}
_ => None,
}
}
#[must_use]
fn is_verified_callback(&self) -> bool {
matches!(
self,
Self::ExternalCallback {
signature_verified: true,
..
}
)
}
#[must_use]
pub fn satisfies_confirmation(&self, confirmation: ConfirmationPolicy) -> bool {
use ConfirmationPolicy as Policy;
use InteractionKind as Kind;
match confirmation {
Policy::None => true,
Policy::ReviewCard | Policy::ExplicitClick => matches!(
self.confirming_kind(),
Some(Kind::ConfirmCommand | Kind::ReviewChanges)
),
Policy::Reauthentication => {
self.confirming_kind() == Some(Kind::Reauthenticate) || self.is_verified_callback()
}
Policy::QualifiedSignature => {
self.confirming_kind() == Some(Kind::ExternalSignature)
|| self.is_verified_callback()
}
Policy::HumanProfessionalReview => {
matches!(self, Self::InternalPolicy { .. }) || self.is_verified_callback()
}
}
}
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum RiskClass {
ReadOnly,
ReversibleLowRisk,
SensitiveDataChange,
Destructive,
Irreversible,
ExternalRegulated,
}
impl RiskClass {
#[must_use]
pub const fn conservative() -> Self {
Self::Irreversible
}
#[must_use]
pub fn needs_trusted_origin(self) -> bool {
self > Self::ReversibleLowRisk
}
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ConfirmationPolicy {
None,
ReviewCard,
ExplicitClick,
Reauthentication,
QualifiedSignature,
HumanProfessionalReview,
}
impl ConfirmationPolicy {
#[must_use]
pub fn interaction_kind(self) -> Option<InteractionKind> {
match self {
Self::None | Self::HumanProfessionalReview => None,
Self::ReviewCard => Some(InteractionKind::ReviewChanges),
Self::ExplicitClick => Some(InteractionKind::ConfirmCommand),
Self::Reauthentication => Some(InteractionKind::Reauthenticate),
Self::QualifiedSignature => Some(InteractionKind::ExternalSignature),
}
}
#[must_use]
pub fn is_server_side_only(self) -> bool {
matches!(self, Self::HumanProfessionalReview)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[non_exhaustive]
pub enum AtomicityScope {
PerCommand,
PerCase,
ExplicitGroup {
group: String,
},
ExternalSaga {
saga: String,
},
}
impl AtomicityScope {
#[must_use]
pub fn discriminant(&self) -> &'static str {
match self {
Self::PerCommand => "per_command",
Self::PerCase => "per_case",
Self::ExplicitGroup { .. } => "explicit_group",
Self::ExternalSaga { .. } => "external_saga",
}
}
#[must_use]
pub fn group_name(&self) -> Option<&str> {
match self {
Self::PerCommand | Self::PerCase => None,
Self::ExplicitGroup { group } => Some(group),
Self::ExternalSaga { saga } => Some(saga),
}
}
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum ClaimMode {
ServerReceiptOnly,
EventReferencedParaphrase,
FreeExplanation,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
pub struct CommandPolicy {
pub risk: RiskClass,
pub confirmation: ConfirmationPolicy,
pub atomicity: AtomicityScope,
pub claim_mode: ClaimMode,
}
impl CommandPolicy {
#[must_use]
pub fn conservative() -> Self {
Self {
risk: RiskClass::Irreversible,
confirmation: ConfirmationPolicy::ExplicitClick,
atomicity: AtomicityScope::PerCase,
claim_mode: ClaimMode::ServerReceiptOnly,
}
}
#[must_use]
pub fn read_only() -> Self {
Self {
risk: RiskClass::ReadOnly,
confirmation: ConfirmationPolicy::None,
atomicity: AtomicityScope::PerCommand,
claim_mode: ClaimMode::FreeExplanation,
}
}
#[must_use]
pub fn low_risk() -> Self {
Self {
risk: RiskClass::ReversibleLowRisk,
confirmation: ConfirmationPolicy::None,
atomicity: AtomicityScope::PerCase,
claim_mode: ClaimMode::EventReferencedParaphrase,
}
}
#[must_use]
pub fn requires_trusted_origin(&self) -> bool {
self.risk > RiskClass::ReversibleLowRisk || self.confirmation != ConfirmationPolicy::None
}
}
impl Default for CommandPolicy {
fn default() -> Self {
Self::conservative()
}
}
#[must_use]
pub fn origin_satisfies(origin: &CommandOrigin, policy: &CommandPolicy) -> bool {
if policy.risk.needs_trusted_origin() && !origin.is_trusted() {
return false;
}
origin.satisfies_confirmation(policy.confirmation)
}
const COMMAND_ID_DOMAIN: &str = "turnframe.command_id.v2";
const BATCH_ID_DOMAIN: &str = "turnframe.batch_id.v1";
impl CommandId {
#[must_use]
pub fn derive(turn_id: &TurnId, act: ActId, position: usize) -> Self {
Self(derive_uuid(
COMMAND_ID_DOMAIN,
&[
&turn_id.to_string(),
&act.to_string(),
&position.to_string(),
],
))
}
}
impl BatchId {
#[must_use]
pub fn derive(turn_id: &TurnId, case_key: &CaseKey, scope: &AtomicityScope) -> Self {
Self(derive_uuid(
BATCH_ID_DOMAIN,
&[
&turn_id.to_string(),
case_key.workflow.as_str(),
case_key.case_id.as_str(),
scope.discriminant(),
scope.group_name().unwrap_or(""),
],
))
}
}
#[derive(
Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
)]
#[serde(transparent)]
pub struct IdempotencyKey(pub String);
const IDEMPOTENCY_DOMAIN: &str = "turnframe.idempotency.v1";
#[derive(Serialize)]
struct IdempotencyInput<'a> {
domain: &'static str,
account: &'a AccountId,
turn_id: &'a TurnId,
case_ref: &'a CaseRef,
origin: &'a CommandOrigin,
command: &'a serde_json::Value,
}
impl IdempotencyKey {
pub fn derive(
account: &AccountId,
turn_id: &TurnId,
case_ref: &CaseRef,
origin: &CommandOrigin,
command: &serde_json::Value,
) -> Result<Self, HashError> {
let input = IdempotencyInput {
domain: IDEMPOTENCY_DOMAIN,
account,
turn_id,
case_ref,
origin,
command,
};
canonical_digest(&input).map(|digest| Self(digest.0))
}
#[must_use]
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for IdempotencyKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CommandEnvelope<C> {
pub command_id: CommandId,
pub turn_id: TurnId,
pub actor: ActorContext,
pub case_ref: CaseRef,
pub idempotency_key: IdempotencyKey,
pub origin: CommandOrigin,
pub command: C,
}
impl<C> CommandEnvelope<C> {
#[must_use]
pub fn account_id(&self) -> &AccountId {
&self.actor.account_id
}
pub fn try_map_command<D, E>(
self,
f: impl FnOnce(C) -> Result<D, E>,
) -> Result<CommandEnvelope<D>, E> {
Ok(CommandEnvelope {
command_id: self.command_id,
turn_id: self.turn_id,
actor: self.actor,
case_ref: self.case_ref,
idempotency_key: self.idempotency_key,
origin: self.origin,
command: f(self.command)?,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CommandBatch<C> {
pub batch_id: BatchId,
pub scope: AtomicityScope,
pub envelopes: Vec<CommandEnvelope<C>>,
}
impl<C> CommandBatch<C> {
#[must_use]
pub fn len(&self) -> usize {
self.envelopes.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.envelopes.is_empty()
}
#[must_use]
pub fn is_single_case(&self) -> bool {
match self.envelopes.split_first() {
None => true,
Some((first, rest)) => rest.iter().all(|e| e.case_ref.same_case(&first.case_ref)),
}
}
pub fn try_map<D, E>(self, mut f: impl FnMut(C) -> Result<D, E>) -> Result<CommandBatch<D>, E> {
let mut envelopes = Vec::with_capacity(self.envelopes.len());
for envelope in self.envelopes {
envelopes.push(envelope.try_map_command(&mut f)?);
}
Ok(CommandBatch {
batch_id: self.batch_id,
scope: self.scope,
envelopes,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ids::CaseRevision;
use crate::understanding::UnitId;
fn card(kind: InteractionKind, action_class: ActionClass) -> CommandOrigin {
CommandOrigin::ConfirmedInteraction {
interaction_id: InteractionId::nil(),
payload_hash: Digest::of_bytes(b"p"),
interaction_kind: kind,
action_class,
channel: ResolutionChannel::Click,
}
}
fn confirmed() -> CommandOrigin {
card(
InteractionKind::ConfirmCommand,
ActionClass::ConfirmsCommands,
)
}
fn direct() -> CommandOrigin {
CommandOrigin::DirectSafeUserAct {
evidence_digest: Digest::of_bytes(b"e"),
}
}
fn internal() -> CommandOrigin {
CommandOrigin::InternalPolicy {
policy_key: "auto".into(),
}
}
fn callback(signature_verified: bool) -> CommandOrigin {
CommandOrigin::ExternalCallback {
callback_id: "c".into(),
signature_verified,
}
}
fn with_confirmation(confirmation: ConfirmationPolicy) -> CommandPolicy {
CommandPolicy {
confirmation,
..CommandPolicy::conservative()
}
}
#[test]
fn conservative_is_default_and_requires_trust() {
assert_eq!(CommandPolicy::default(), CommandPolicy::conservative());
assert!(CommandPolicy::conservative().requires_trusted_origin());
assert!(!CommandPolicy::low_risk().requires_trusted_origin());
assert!(!CommandPolicy::read_only().requires_trusted_origin());
}
#[test]
fn origin_satisfies_rules() {
assert!(origin_satisfies(&direct(), &CommandPolicy::low_risk()));
assert!(!origin_satisfies(&direct(), &CommandPolicy::conservative()));
assert!(origin_satisfies(
&confirmed(),
&CommandPolicy::conservative()
));
let mut low_with_review = CommandPolicy::low_risk();
low_with_review.confirmation = ConfirmationPolicy::ReviewCard;
assert!(!origin_satisfies(&direct(), &low_with_review));
assert!(!origin_satisfies(
&callback(false),
&CommandPolicy::conservative()
));
assert!(!origin_satisfies(
&callback(true),
&CommandPolicy::conservative()
));
assert!(origin_satisfies(
&callback(true),
&with_confirmation(ConfirmationPolicy::QualifiedSignature)
));
assert!(!origin_satisfies(
&internal(),
&CommandPolicy::conservative()
));
assert!(origin_satisfies(
&internal(),
&with_confirmation(ConfirmationPolicy::HumanProfessionalReview)
));
}
#[test]
fn answering_a_selection_card_confirms_nothing() {
let selection = card(InteractionKind::SelectTarget, ActionClass::NoCommands);
assert!(!selection.is_trusted());
assert!(!origin_satisfies(
&selection,
&CommandPolicy::conservative()
));
assert!(origin_satisfies(&selection, &CommandPolicy::low_risk()));
let dismissed = card(InteractionKind::ConfirmCommand, ActionClass::NoCommands);
assert!(!origin_satisfies(
&dismissed,
&CommandPolicy::conservative()
));
}
#[test]
fn each_confirmation_policy_accepts_only_its_own_authority() {
use ConfirmationPolicy as P;
use InteractionKind as K;
let cases: &[(P, &[K])] = &[
(P::ReviewCard, &[K::ConfirmCommand, K::ReviewChanges]),
(P::ExplicitClick, &[K::ConfirmCommand, K::ReviewChanges]),
(P::Reauthentication, &[K::Reauthenticate]),
(P::QualifiedSignature, &[K::ExternalSignature]),
(P::HumanProfessionalReview, &[]),
];
let every_kind = [
K::Boolean,
K::SingleSelect,
K::MultiSelect,
K::Freeform,
K::ReviewChanges,
K::ConfirmCommand,
K::SelectTarget,
K::ResolveValidationError,
K::Reauthenticate,
K::ExternalSignature,
];
for (confirmation, accepted) in cases {
let policy = with_confirmation(*confirmation);
for kind in every_kind {
let origin = card(kind, ActionClass::ConfirmsCommands);
assert_eq!(
origin_satisfies(&origin, &policy),
accepted.contains(&kind),
"{confirmation:?} vs {kind:?}"
);
}
}
}
#[test]
fn human_professional_review_is_not_the_users_own_click() {
let policy = with_confirmation(ConfirmationPolicy::HumanProfessionalReview);
assert!(!origin_satisfies(&confirmed(), &policy));
assert_eq!(
ConfirmationPolicy::HumanProfessionalReview.interaction_kind(),
None
);
assert!(ConfirmationPolicy::HumanProfessionalReview.is_server_side_only());
assert!(origin_satisfies(&internal(), &policy));
assert!(origin_satisfies(&callback(true), &policy));
assert!(!origin_satisfies(&callback(false), &policy));
}
#[test]
fn model_interpreted_answers_never_authorize_above_low_risk() {
let interpreted = CommandOrigin::ConfirmedInteraction {
interaction_id: InteractionId::nil(),
payload_hash: Digest::of_bytes(b"p"),
interaction_kind: InteractionKind::ConfirmCommand,
action_class: ActionClass::ConfirmsCommands,
channel: ResolutionChannel::ModelInterpreted,
};
assert!(!interpreted.is_trusted());
assert!(!origin_satisfies(
&interpreted,
&CommandPolicy::conservative()
));
assert!(origin_satisfies(&interpreted, &CommandPolicy::low_risk()));
let mut low_but_confirmed = CommandPolicy::low_risk();
low_but_confirmed.confirmation = ConfirmationPolicy::ExplicitClick;
assert!(!origin_satisfies(&interpreted, &low_but_confirmed));
}
#[test]
fn derived_ids_are_deterministic_and_positional() {
let turn = TurnId::nil();
let (first, second) = (ActId::new(UnitId(1), 1), ActId::new(UnitId(2), 1));
let a = CommandId::derive(&turn, first, 0);
assert_eq!(a, CommandId::derive(&turn, first, 0));
assert_ne!(a, CommandId::derive(&turn, first, 1));
assert_ne!(a, CommandId::derive(&turn, second, 0));
assert_ne!(a, CommandId::derive(&TurnId::new(), first, 0));
let key = CaseKey::new("trip", "i1");
let b = BatchId::derive(&turn, &key, &AtomicityScope::PerCase);
assert_eq!(b, BatchId::derive(&turn, &key, &AtomicityScope::PerCase));
assert_ne!(b, BatchId::derive(&turn, &key, &AtomicityScope::PerCommand));
assert_ne!(
b,
BatchId::derive(&turn, &CaseKey::new("trip", "i2"), &AtomicityScope::PerCase)
);
assert_ne!(
BatchId::derive(
&turn,
&key,
&AtomicityScope::ExplicitGroup { group: "a".into() }
),
BatchId::derive(
&turn,
&key,
&AtomicityScope::ExplicitGroup { group: "b".into() }
)
);
}
#[test]
fn risk_ordering_matches_declaration() {
assert!(RiskClass::ReadOnly < RiskClass::ReversibleLowRisk);
assert!(RiskClass::Irreversible < RiskClass::ExternalRegulated);
}
#[test]
fn idempotency_key_is_deterministic() {
let account = AccountId::from("acct");
let turn = TurnId::nil();
let case_ref = CaseRef::new("trip", "i1", CaseRevision(1));
let cmd = serde_json::json!({"set_subject": {"value": "x"}});
let a = IdempotencyKey::derive(&account, &turn, &case_ref, &direct(), &cmd).unwrap();
let b = IdempotencyKey::derive(&account, &turn, &case_ref, &direct(), &cmd).unwrap();
assert_eq!(a, b);
let other_rev = case_ref.with_revision(CaseRevision(2));
let c = IdempotencyKey::derive(&account, &turn, &other_rev, &direct(), &cmd).unwrap();
assert_ne!(a, c);
}
}