validate
validate<R = Current>(validator: Validator<Current, R> | CallableValidator<Current, R>): RouteBuilder<Validated<Current, R>>
Validate the exchange body using a Validator adapter or callable function. On success the (possibly coerced) return value replaces the body. On failure the adapter throws and the route error handler (if configured) or the default error path handles it. The body after the step is typed by what the validator returns, less any refusal: a validator that can only refuse leaves the type as it was, since nothing after it runs on a refusal, and one typed never (it always throws) leaves it never, so a .choice() branch may end in one whatever the branches' common type.
A validator that returns or throws refuse(reason, { kind }) refuses the caller rather than failing the instance: the run fails with RC5068 bound to the route, and a door that dispatched the route (the http() source, the ops dispatch mount, an MCP tool call, the ACP mount) answers with the kind and the reason, exactly as it answers a plugin's validate hook. A plain throw stays the instance's failure and reaches a caller through that door's generic failure answer, at most the code (the http() source sends a bare 500), so an unexpected error never leaks its reason. Of the refusal, the kind and the clipped reason are what cross the wire, beside the door's own status or error code and the RC5068 code: never the step's label, the route or the error message. The kind is forbidden unless given, and the reason is clipped to 256 characters at the door. Write the reason for the refused party, never with anything the instance owns, and remember that an unauthenticated or forbidden refusal, its kind and its reason alike, tells a prober which check it tripped. The place for this is a rule that needs state the .input() schema cannot see: a page that already exists, a classifier's verdict, a record outside the caller's reach. Shape rules belong in .input(), which refuses at the door with the schema's issues, and who may ask in .authorize().
For Standard Schema validation, use the .schema() sugar or pass the schema() factory.
// Custom validator
.validate((exchange) => {
if (!exchange.body.email) throw new Error("email required");
return exchange.body;
})
// Standard Schema via factory
import { schema } from '@routecraft/routecraft'
.validate(schema(z.object({ name: z.string() })))
// A rule that needs state: refuse the caller with a reason a door forwards
import { only, refuse } from '@routecraft/routecraft'
.enrich(findExistingPage, only((page) => page, "existing"))
.validate((exchange) =>
exchange.body.existing === undefined
? exchange.body
: refuse("A page with this title already exists.", { kind: "conflict" }),
)
The kinds a refusal can carry, and the status or error each door renders them as, are listed under RC5068. A refusal raised inside a nested direct() call is that call's failure, never the outer caller's. Only .validate() turns a returned or thrown refusal into RC5068. Returned from a body-producing step such as .transform() or .enrich() it is an ordinary body value and flows on; returned from another operation it follows that operation's own return contract (a .filter() predicate reads it as truthy); thrown from any other step it is that step's failure, the instance's.