wakuwaku
Validation (Proof)

Validation (Proof)

Make "forgot to validate" a compile error by passing proofs of validation instead of raw values.

wakuwaku::proof (the wakuwaku-proof crate) is proof-carrying validation. You describe a rule as a predicate, run it once with prove, and get back a Proven<T, P>: the value together with the evidence that the rule held. Code that needs a validated value asks for Proven<T, P> in its signature, so an unvalidated value cannot reach it.

The bug this prevents

Most validation bugs are not wrong rules. They are rules that never ran:

// Every caller has to remember to validate `name` first.
fn register(name: String) { /* ... */ }

A validated String and an unvalidated String have the same type, so nothing stops a new endpoint, a background job, or a refactor from calling register with raw input. If you come from Spring, this is the classic missing @Valid on a controller parameter: the constraints are declared, but nothing forces them to be checked.

With wakuwaku::proof the requirement moves into the type:

fn register(name: Proven<String, ValidUsername>) { /* ... */ }

The only way to get a Proven<String, ValidUsername> is to run the ValidUsername check (or to derive it from another proof). Forgetting to validate becomes a type error.

prove turns a subject into Proven<T, P> or an errorsubject: TP: Predicate<T>ctx: &P::Ctxorder: CheckOrderprove::<T, P>P::check(&s, ctx, order)async, may do I/OOk(Proven<T, P>)subject() → &Tevidence() → &PErr(Deny(DeniedReason))decided: P does not holdErr(IoError(E))P could not be decidedOn an error the subject is dropped.Downstream codeProven<T, P>is all it acceptsnot a bare T or PChange the proposition without recheckingProven<T, And<A, B>>derive()CommuteProven<T, And<B, A>>Proven<T, And<A, B>>into_a()Proven<T, A>Proven<T, A>from_a()Proven<T, Or<A, B>>No recheck, no I/O, cannot fail.What the evidence holdsAndEvidence<A>Evidence<B>Or::LeftEvidence<A>nothing knownOr::Rightnothing knownEvidence<B>Or::BothEvidence<A>Evidence<B>
The predicate type is its own evidence. Proven<T, P> can only come from prove or from another proof, so a function that takes it cannot receive an unchecked value.

Setup

The proof feature is on by default, so wakuwaku alone is enough. Predicates are plain async code; you only need tokio directly for #[tokio::main] and #[tokio::test]. Inside a wakuwaku service, gRPC handlers already run on a runtime.

Cargo.toml
[dependencies]
wakuwaku = "1.0.0-rc3"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

The pieces

ItemRole
ValidateContextWhat a check reads while deciding (configuration, connection handles, caches), plus the error type for "could not decide".
Predicate<T>A proposition about a T, decided at runtime. The predicate type is also its own evidence.
proveRuns a predicate against a subject and wraps both in a Proven on success.
PureVerifier<T, P, B = T>A pure check as a function pointer over &B, a borrowed form of T, declared as a static or const. The other way to obtain a Proven.
Proven<T, P>The subject plus evidence that P held. Only obtainable from prove, a PureVerifier, or another proof. Debug when T and P are.
ValidateError<E>Deny(DeniedReason) when the rule is false, IoError(E) when it could not be decided. A std::error::Error when E is Debug + Display.
DeniedReasonA &'static str explaining a denial, compared by identity. Displays as its text.

A context

Every predicate names its context type. A context implements ValidateContext, which fixes the error type for checks that fail to decide. Pure checks cannot fail that way, so they use Infallible:

src/validation.rs
use std::convert::Infallible;
use wakuwaku::proof::{
    And, CheckOrder, DeniedReason, Predicate, Proven, ValidateContext, ValidateError, prove,
};

/// Everything the checks read. Here: configuration only.
#[derive(Clone)]
pub struct Limits {
    pub max_len: usize,
}

impl ValidateContext for Limits {
    // Pure checks cannot fail to decide.
    type IoError = Infallible;
}

Predicates

A predicate is a type that implements Predicate<T>. check takes no self: everything the decision needs comes from the subject and the context. When the rule holds, check returns a value of the predicate type itself. That value is the evidence. A rule with nothing to report is a unit struct; a rule that learns something while deciding stores it in its fields.

src/validation.rs
pub static EMPTY: DeniedReason = DeniedReason("username is empty");
pub static TOO_LONG: DeniedReason = DeniedReason("username is too long");

/// "The name is not empty." Nothing to report, so a unit struct.
pub struct NonEmpty;

impl Predicate<String> for NonEmpty {
    type Ctx = Limits;

    async fn check(
        name: &String,
        _: &Limits,
        _: CheckOrder,
    ) -> Result<Self, ValidateError<Infallible>> {
        if name.is_empty() {
            Err(ValidateError::Deny(EMPTY))
        } else {
            Ok(NonEmpty)
        }
    }
}

/// "The name fits in `max_len` bytes." Records how much room was left.
pub struct ShortEnough {
    pub spare: usize,
}

impl Predicate<String> for ShortEnough {
    type Ctx = Limits;

    async fn check(
        name: &String,
        limits: &Limits,
        _: CheckOrder,
    ) -> Result<Self, ValidateError<Infallible>> {
        match limits.max_len.checked_sub(name.len()) {
            Some(spare) => Ok(ShortEnough { spare }),
            None => Err(ValidateError::Deny(TOO_LONG)),
        }
    }
}

The future returned by check must be Send, so a check can run inside any tonic handler. The CheckOrder argument only matters to predicates that combine others; leaf predicates may ignore it (see Composing predicates).

Proving

prove takes the subject by value, runs the check, and returns either the proof or the error. And<NonEmpty, ShortEnough> is itself a predicate that holds when both hold; Composing predicates covers it in detail.

src/validation.rs
/// Both must hold.
pub type ValidUsername = And<NonEmpty, ShortEnough>;

/// Only callable with a name that went through `prove`.
pub fn register(name: Proven<String, ValidUsername>) -> String {
    let And(_, short_enough) = name.evidence();
    format!("{} ({} bytes to spare)", name.subject(), short_enough.spare)
}

pub async fn handle(input: String) -> Result<String, &'static str> {
    let limits = Limits { max_len: 16 };

    match prove::<_, ValidUsername>(input, &limits, CheckOrder::Sequential).await {
        Ok(name) => Ok(register(name)),
        Err(ValidateError::Deny(reason)) => Err(reason.0),
        // `Infallible` has no values: this arm can never run.
        Err(ValidateError::IoError(never)) => match never {},
    }
}

handle("haruki".into()) returns Ok("haruki (10 bytes to spare)"); an empty string returns Err("username is empty"). When the check fails, the subject is dropped along with the error.

Pure checks: PureVerifier

Some rules read nothing but the subject: a slug format, a range, a checksum. They need no context, cannot fail to decide, and have nothing to compose. For those, a PureVerifier<T, P, B> is the check itself as a function pointer, fn(&B) -> Result<P, ValidateError<Infallible>>, where B is a borrowed form of the subject type T (T: Borrow<B>) and defaults to T. PureVerifier::new is a const fn, so the verifier is declared once as a static (or const) next to its evidence type, and proving is a plain synchronous call:

src/validation/slug.rs
use std::convert::Infallible;
use wakuwaku::proof::{DeniedReason, Proven, PureVerifier, ValidateError};

pub static NOT_A_SLUG: DeniedReason = DeniedReason("not a lowercase slug");

/// "The string is a URL slug." The private field: only this module can build one.
pub struct Slug(());

fn is_slug(s: &str) -> Result<Slug, ValidateError<Infallible>> {
    let valid = !s.is_empty()
        && s.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
    if valid { Ok(Slug(())) } else { Err(ValidateError::Deny(NOT_A_SLUG)) }
}

/// Owns a `String`, checks it as `&str`.
pub static SLUG: PureVerifier<String, Slug, str> = PureVerifier::new(is_slug);

/// Only callable with a string that went through `SLUG`.
pub fn publish(slug: Proven<String, Slug>) -> String {
    format!("/posts/{}", slug.subject())
}

pub fn handle(input: String) -> Result<String, &'static str> {
    match SLUG.prove(input) {
        Ok(slug) => Ok(publish(slug)),
        Err(ValidateError::Deny(reason)) => Err(reason.0),
        // The I/O error type is `Infallible`: a pure check can only deny.
        Err(ValidateError::IoError(never)) => match never {},
    }
}

The result is an ordinary Proven<String, Slug>; code that requires it cannot tell, and does not care, which way it was obtained.

Checking through a borrow. The third parameter lets the function take the subject in its borrowed form: SLUG owns a String and checks it as &str, and a verifier for Vec<u8> can check &[u8]. The same is_slug also serves PureVerifier<Box<str>, Slug, str>. Leave B out and it is T, so the function takes &T. The proof is only as sound as T's Borrow<B>: the function decides P for the borrowed view, and Borrow's contract, that the borrowed form behaves like the owned value, is what makes that view stand for the subject. The standard library's impls honor it; a hand-written Borrow that returns some other view would prove P about that view instead.

Who can write a verifier. The function must return a Slug, so only code that can construct a Slug can write a verifier for it. That is why Slug has a private field. A predicate's meaning is fixed by its single impl Predicate<T>, but any number of verifiers can target the same evidence type, and a unit struct like pub struct NonEmpty; would let anyone write PureVerifier::new(|_| Ok(NonEmpty)) and prove anything. With a private field, every proof of Slug is written in the module that defines it, which is where the rule's meaning lives.

When to use which.

Predicate + provePureVerifier
Readssubject and a ValidateContext (configuration, database handles, …)the subject only
Outcomesholds, Deny, IoErrorholds, Deny
Callasync, takes a CheckOrdersynchronous, usable in constructors and TryFrom impls
Composes with And / Or and the derivation lawsyesno: P need not implement Predicate

Reach for a PureVerifier when the rule is a local property of the value and you want it checked where the value is created, without a context type or an .await. Use a predicate when the rule needs configuration or I/O, or when it is one operand of a larger rule. A verifier's proof still joins others through Proven::from_both, from_a and from_b, which do not require a predicate.

Three outcomes

A check ends in exactly one of three ways:

ResultMeaningTypical handling
Ok(evidence)The proposition holds. prove returns Proven<T, P>.Continue with the proof.
Err(ValidateError::Deny(reason))The proposition was decided and is false.Reject the input (INVALID_ARGUMENT, ALREADY_EXISTS, …).
Err(ValidateError::IoError(e))The proposition could not be decided, e.g. the database was unreachable.Treat as a server-side failure (UNAVAILABLE), not as bad input.

An I/O error is not a denial

IoError says nothing about whether the subject is valid. Reporting it to the client as "invalid input" blames the user for an outage. Keep the two arms separate; Using proofs in services shows a mapping to tonic::Status.

As a standard error

ValidateError<E> implements std::error::Error and Display whenever E is Debug + Display, which includes Infallible. Deny displays as the reason's text, IoError as could not decide the proposition: followed by the I/O error. Where the caller does not need to tell the two apart, such as a batch job that stops at the first failure, ? propagates it into a Box<dyn Error> or an anyhow::Error:

src/validation.rs
use std::error::Error;

/// Any invalid name aborts the import.
pub async fn import(names: Vec<String>) -> Result<Vec<String>, Box<dyn Error>> {
    let limits = Limits { max_len: 16 };
    let mut registered = Vec::new();
    for name in names {
        let name = prove::<_, ValidUsername>(name, &limits, CheckOrder::Sequential).await?;
        registered.push(register(name));
    }
    Ok(registered)
}

import(vec![String::new()]) fails with an error that displays as username is empty.

Denied reasons

DeniedReason wraps a &'static str, and its equality is pointer identity, not string content. Two reasons with the same text may compare unequal if they live at different addresses, and string literals or references to a const are not guaranteed to share one. Declare each reason once as a static and compare against that static:

pub static TAKEN: DeniedReason = DeniedReason("username is already taken");

// Later, when mapping errors:
if reason == TAKEN { /* ... */ }

DeniedReason implements Display, which writes its text, so format!("rejected: {reason}") works. reason.0 is the &'static str itself, for APIs that take one.

Evidence

The predicate type is its own evidence. Proven::evidence() hands it out by reference:

  • NonEmpty carries nothing; holding the proof is the information.
  • ShortEnough { spare } records how much room was left, so register reads short_enough.spare without measuring again.
  • An I/O predicate can carry the database row it looked up (see I/O predicates), so later code does not repeat the query.

Inside Proven, And and Or, each piece of evidence is wrapped in Evidence<P>. It dereferences to &P, it cannot be moved out, and it is not Clone, even when P is. Only the crate's Boolean-algebra laws duplicate evidence, and only those whose result contains the same proof twice; those require P: Clone (see Deriving proofs).

The subject is also exposed only as &T through Proven::subject(), so it cannot be changed after the check (interior mutability aside). There is no method that gives the subject back by value; clone it if you need an owned copy.

Proven<T, P> implements Debug when T and P do (as do Evidence, And and Or), so a struct that holds a proof can #[derive(Debug)] once the subject and predicate types implement Debug.

A proof is a snapshot

Both subject and evidence describe the moment of the check. If the predicate depends on external state, such as "this username is free", that state may have changed since. Keep the database's own constraints (for example a unique index) as the final guard.

Never accept a bare predicate

It is tempting to require the evidence type directly:

// Do not do this: proves nothing.
fn register(name: String, _proof: NonEmpty) { /* ... */ }

Anyone can write NonEmpty, and calling Predicate::check directly returns evidence that is not tied to any subject. Take Proven<T, P> in signatures, never a bare P.

Proven is safe to require because its fields are private. Outside the crate a Proven comes only from prove, from a PureVerifier, or from another proof through Proven::from_a, from_b, from_both, into_a, into_b and derive. Both shortcuts fail to compile:

let input = String::from("haruki");
register(input); // error[E0308]: mismatched types
                 // expected `Proven<String, And<NonEmpty, ShortEnough>>`, found `String`

let fake = Proven { subject: String::new(), evidence: todo!() };
// error[E0451]: fields `subject` and `evidence` of struct `Proven` are private

Next steps

On this page