DecisionNode is a decision engine: send it text, JSON or images with a question, and it answers with calibrated probabilities.
The official Rust client for the DecisionNode API: typed truth, choice, score, number, point and box decisions over any state, with up to 16 images, videos sent as frames, boosters, knowledge bases of your own documents, fine-tuned models, sessions, batches, retries and typed errors. Async on reqwest and Tokio, with an optional blocking client.
The client checks every request against the API's fixed rules before it sends anything, reuses its connections, and picks the fastest form for each image, so a call costs one round trip wherever one is enough.
Not on crates.io yet: the first release publishes it there. Until then, add it from GitHub:
cargo add bynn-decisionnode --git https://github.com/Bynn-Intelligence/decision-node-rustOnce it is on crates.io:
cargo add bynn-decisionnodeThe crate is imported as decisionnode. For the synchronous client, enable the blocking feature:
cargo add bynn-decisionnode --features blockingexport DECISIONNODE_API_KEY="dn_..."use decisionnode::{choice, score, truth, DecideRequest, DecisionNode};
use serde_json::json;
#[tokio::main]
async fn main() -> decisionnode::Result<()> {
let dn = DecisionNode::new(); // reads DECISIONNODE_API_KEY
let d = dn
.decide(
DecideRequest::new()
.state(json!({"ticket": "I was charged twice and nobody has replied for 3 days."}))
.question("route", choice("Where should this go?", [("billing", "money"), ("bug", "broken"), ("account", "login")]))
.question("urgency", score("How urgent is this?", ["routine", "today", "urgent", "critical"]))
.question("escalate", truth("Escalate to a human now?")),
)
.await?;
d["route"].choice(); // Some("billing")
&d["route"].as_choice().unwrap().probabilities; // {"billing": 0.87, "bug": 0.08, "account": 0.05}
d["urgency"].score(); // Some(2.31)
d["escalate"].truth(); // Some(0.94)
Ok(())
}The same with the blocking client:
use decisionnode::blocking::DecisionNode;
let dn = DecisionNode::new();
let answer = dn.check("Customer: please refund my last order.", "Is this a refund request?")?;
println!("{}", answer.truth); // 0.97| Method | Sends | Returns |
|---|---|---|
decide(request) |
One POST /v1/decide |
Decision |
decide_many(requests), decide_many_with(requests, concurrency) |
One call per request, at most 8 (or concurrency) at a time |
Result<Vec<DecisionResult>>: one DecisionResult (a Result<Decision>) per request, in input order. One failure does not stop the others. Every request is checked before any is sent: an invalid one (an empty question map, a value that cannot be serialised), or a concurrency below 1, is an argument error (Err of the base kind, see Errors) and nothing is sent. |
check(state, question) |
One truth question, id check |
TruthAnswer. A missing or malformed answer is a Server error (below) |
classify(state, choice(...)) |
One choice question, id classify |
ChoiceAnswer, the same |
score(state, score(...)) |
One score question, id score |
ScoreAnswer, the same |
number(state, number(...)) |
One number question, id number |
NumberAnswer, the same |
gate(action, context) |
Three fixed questions, below | Decision |
models() |
GET /v1/models |
Vec<ModelInfo> with each model's published limits (number grid, upload, sessions and batch limits included) |
upload(images) |
POST /v1/uploads and one PUT per image not uploaded before |
One upload id per image |
collections(), upload_documents(documents) |
Collections of faces and knowledge bases, see Knowledge | The API's objects as sent; a search's KnowledgeReading |
open_session(options), supervise(options, feed, on_reply) |
Sessions, see Sessions | Session |
batches(), run_batch(requests) |
The Batch API, see Batches | Batch, BatchResult |
usage() |
GET /v1/keys/{key_id}/usage and GET /v1/usage, see Usage by key |
The API's objects as sent |
keys() |
/v1/keys, see Device keys |
The API's objects as sent |
check_decide(&request), check_session(&options), check_batch(&requests), check_batch_add(&requests), Session::check_frame(&frame) |
Nothing: every local check the call would run | Ok(()), or the error the call would return |
decide_raw(body) |
The body exactly as given | The response body as serde_json::Value, for API fields this version does not know yet |
check_with, classify_with, score_with, number_with and gate_with take CallOptions for images, videos, boosters and a model, like decide:
use decisionnode::{CallOptions, Image};
let answer = dn
.check_with(
json!({"claim": {"total": "740.00"}}),
"Does the total on the receipt equal the total in the claim?",
CallOptions::new().image(Image::url("receipt", "https://files.example.com/receipt.jpg")).model("decisionnode-flash"),
)
.await?;gate sends the state {"action": action, "context": context} and these three questions, worded the same in every DecisionNode SDK (gate_questions() returns them):
| Id | Type | Instructions | Criteria |
|---|---|---|---|
risk |
score | How much harm could this action cause if it is wrong? | none, low, moderate, high, severe |
touches_production |
truth | Does this action change a production system, production data or anything customers use? | |
verdict |
choice | Should this action run as it is, wait for a person to confirm it, or be blocked? | allow "safe to run without asking", confirm "a person should confirm it first", block "should not run" |
A client is cheap to clone and safe to share between tasks and threads; clones share one connection pool and one upload cache.
| Builder | Wire form |
|---|---|
truth(instructions), .if_true(...), .if_false(...) |
{"type": "truth", "instructions": ..., "criteria": {"true": ..., "false": ...}}, criteria left out when neither side is given |
choice(instructions, options) |
{"type": "choice", "instructions": ..., "criteria": {name: description}} |
score(instructions, levels) |
{"type": "score", "instructions": ..., "criteria": [levels, low to high]} |
number(instructions), .min(...), .max(...), .step(...) |
{"type": "number", "instructions": ..., "min": ..., "max": ..., "step": ...}, each bound left out unless given, so the API's defaults apply (0, 255, 1) |
point(instructions), .image(...), .units(...) |
{"type": "point", "instructions": ..., "image": ..., "units": ...}, image and units left out unless given |
bounding_box(instructions), .image(...), .units(...) |
{"type": "box", ...}, built like point (box is a reserved word in Rust) |
The state, instructions, descriptions and levels take a string or any value that implements serde::Serialize. Options are a list of names (["billing", "bug"]), name and description pairs, or a map. Truth::new(), Choice::new(options) and Score::new(levels) build a question without instructions. A serde_json::Value in the wire form is accepted wherever a builder is:
let request = DecideRequest::new()
.state("Is hotdog a sandwich?")
.question("q", json!({"type": "truth", "criteria": {"true": "bread around a filling", "false": null}}));A point or a box is a place in one of the request's images:
use decisionnode::{bounding_box, point, Image};
let d = dn
.decide(
DecideRequest::new()
.image(Image::url("belt", "https://files.example.com/line-3/cam-2/0917.jpg"))
.question("pick", point("Point to the yellow package closest to the robot arm."))
.question("damage", bounding_box("Draw a box around the damaged package.").image("belt"))
.question("any_damage", truth("Is any package on the belt damaged?")),
)
.await?;
let pick = d["pick"].as_point().unwrap(); // x 812, y 455, present 0.97
let damage = d["damage"].as_box().unwrap(); // x1 300, y1 120, x2 520, y2 410x and y are measured from the top-left corner of the image, x to the right and y down; a box runs from its top-left corner (x1, y1) to its bottom-right corner (x2, y2). units is "pixels" (the default: pixels of the image as sent) or "normalized" (fractions of the width and the height). present is the probability that what the instructions describe is in the image at all; confidence the probability that the place is right if it is. image, width and height name the image looked in and its size in pixels. The request needs an image, and .image(id) names the one to look in by its id when it carries two or more; a reference by number ("Picture 2") goes in the instructions.
A question id is any string and comes back as the key of its answer. Without a state the request sends "".
| Type | Fields |
|---|---|
Decision |
answers (by question id, also decision["id"] and decision.get("id")), model, usage.input_tokens, usage.output_tokens, usage.video_frames, usage.video_seconds, usage.booster_tokens, usage.boosters, usage.cost, warnings, evidence, request_id, latency_ms, safety, raw |
ModelInfo |
name, description, release_date, fine_tuned, base, adapter_version, status (the last four on a fine-tuned model only), price_per_million_input, price_per_million_output, price_per_million_image, price_per_million_video (USD per million tokens) and batch_price_factor (the share of the live prices a batch line pays; none on a fine-tuned model), each None when the entry does not state it, limits (every other field of the entry, the prices included), raw |
Answer::Truth(TruthAnswer) |
truth |
Answer::Choice(ChoiceAnswer) |
choice, confidence, probabilities (by option name, exactly as sent) |
Answer::Score(ScoreAnswer) |
score, confidence, probabilities (by level index), legend (level index to the level as sent) |
Answer::Number(NumberAnswer) |
number, expected, confidence, probabilities (by the value exactly as the API writes it, "7", "3.5") |
Answer::Point(PointAnswer) |
x, y, present, confidence, image, width, height |
Answer::Box(BoxAnswer) |
x1, y1, x2, y2, present, confidence, image, width, height |
Answer::Unknown(UnknownAnswer) |
answer_type and raw, for a question type this version does not know, or a known type whose fields are missing or of the wrong type. Such an answer never fails the decision. |
model is read from the body; request_id, latency_ms and safety from the x-request-id, x-decisionnode-latency-ms and x-decisionnode-safety headers. A value the response does not carry is None: the token counts are Option<u64> and latency_ms is Option<f64>, never 0 in place of a missing value. For a request with videos, usage.video_frames is the number of frames read and usage.video_seconds the seconds of the windows read (None without videos), and warnings holds the server's notes exactly as sent when it read a video at a lower rate or resolution than asked (empty when there are none).
usage.cost is what the answer cost in USD, as the API booked it: usd (the amount booked), model_usd (the model's part), boosters_usd (the boosters' part) and free_tier (true when the free tier covered the model's part). Every amount is read as sent and never computed by the client, so usd is the API's own figure, not model_usd plus boosters_usd. A batch result's decision and a session's open and frame replies carry it too; it is None from a server that does not send it, never zeros.
Numbers are passed through as received: no rounding and no renormalising, so a probability list may sum to 0.99 or 1.01 and an entry may read 0.8200000000000001. Read answers by id, never by position.
Send an image by URL when you can. It is the fastest form: nothing is uploaded, the request stays small, and the API fetches the image while it reads the request.
use decisionnode::Image;
let request = DecideRequest::new()
.state(json!({"claim": {"total": "740.00", "currency": "SEK"}}))
.image(Image::url("receipt", "https://files.example.com/claims/4411/receipt.jpg")) // recommended
.image(Image::path("scan", "scan.jpg")?) // read now
.image(Image::bytes("photo", png_bytes)) // type from the bytes
.question("total_matches", truth("Does the total on the receipt equal the total in the claim?"));| Form | What is sent |
|---|---|
Image::url(id, url) |
{"id", "url"}, never fetched by the client. Checked first: https and at most 2,048 characters |
Image::path(id, path), Image::bytes(id, data), Image::bytes_with_type(id, data, media_type), Image::base64(id, text) |
Checked locally, then inline as base64 when all the request's local images total under the inline threshold (DEFAULT_INLINE_THRESHOLD, 512 KiB, set with ClientBuilder::inline_threshold), and uploaded at or above it: one POST /v1/uploads for all of them, the PUTs in parallel, then the request names them by upload_id |
| An image the client uploaded before | Its cached upload_id, whatever its size, until 5 minutes before the upload expires |
Image::upload_id(id, upload_id) |
{"id", "upload_id"} as given |
The local checks are the API's own: JPEG, PNG or WebP by the first bytes (a declared type must match them), at most 10 MB, at most 40 megapixels read from the image header, at most 16 images in a request (URLs included; a 17th is the API's Validation error with a too_long entry), ids of at most 64 characters (counted as Unicode code points, not bytes) and unique. A session frame's image goes without its id, so its id is not checked for length. upload takes up to 16 images a call. A file is read when the Image is made, so a file that cannot be read fails there. No image is resized or re-encoded.
A request body may be at most 26,214,400 bytes (25 MiB, MAX_BODY_BYTES), inline images and video frames included. The client works out the body as it would send it before sending anything, and a larger one fails with a RequestTooLarge error and nothing is sent or uploaded; the same holds for each request of decide_many and each batch line. Images given as a path or bytes above the inline threshold are uploaded, so a request with images stays far below the limit; give large images as a path or bytes, or by Image::upload_id, rather than as base64 in a wire-form map.
The upload cache lives in the client's memory only: by SHA-256 of the bytes and the media type, at most 256 entries, never written to disk and never shared between clients. Requests holding the same bytes at the same time share one upload. If the API answers that an upload the client placed has expired, was consumed or is not there, the client uploads the image again and resends the request once.
The PUT goes to the storage URL the API returned, with exactly the headers it returned: never the API key, never the client's own headers, and no redirect followed. A PUT answered 403 after its URL's 5 minutes have passed (a slow network, a paused process) is presigned again once. Each PUT attempt has its own timeout, 60 seconds by default (ClientBuilder::upload_timeout), apart from the API call's 10. upload(images) uploads whatever the size and returns the ids, for images you send many times:
let ids = dn.upload([Image::path("receipt", "receipt.jpg")?]).await?;
let again = DecideRequest::new().image(Image::upload_id("receipt", &ids[0])).question("q", truth("Is it paid?"));There are two ways to say which image a question is about:
- By id (recommended). Give each image a short, meaningful id and use it in the instructions, in backticks: "Does the total on
receiptmatch the amount in the claim?" - By number. Images are numbered from 1 in the order of
images: "image 2", "the second image", "Picture 2" and "the last image" all work.
The model sees each image as Picture 1 (receipt):, Picture 2 (claim_photo):, so both ways point to the same image. Prefer ids: they stay right when images are added or reordered. Use names, not digits, as ids (front, not 2). Videos work the same way, by id or by number ("video 1"), numbered separately in the order of videos. The image of a point or box question takes the id.
let request = DecideRequest::new()
.state("Expense claim 4411: 740.00 EUR.")
.image(Image::url("receipt", "https://files.example.com/claims/4411/receipt.jpg"))
.image(Image::url("claim_photo", "https://files.example.com/claims/4411/photo.jpg"))
.question("matches", truth("Does the total on `receipt` match the amount in the claim?"));A video travels as the frames you already hold, each with its time in seconds, and the server reads them as one video, in order and with their times. Only frames are sent: no video file, no video URL and no sound.
use decisionnode::{number, Video, VideoFrame};
let mut dock = Video::new("dock");
for (i, path) in ["dock-0000.jpg", "dock-0001.jpg", "dock-0002.jpg"].iter().enumerate() {
dock = dock.frame(VideoFrame::bytes(i as f64 * 0.5, std::fs::read(path)?)); // 0, 0.5 and 1 s
}
let d = dn
.decide(
DecideRequest::new()
.state("Warehouse camera 4, loading dock, shift B.")
.video(dock)
.question("fall", truth("Does a person fall in the video?"))
.question("forklifts", number("How many forklifts pass the door?").min(0).max(50)),
)
.await?;
println!("{:?} {:?}", d["fall"].truth(), d.warnings);| Form | What is sent |
|---|---|
VideoFrame::bytes(t, data), VideoFrame::bytes_with_type(t, data, media_type) |
A frame t seconds into the video, its bytes as base64; without a media type it is read from the first bytes (JPEG, PNG or WebP) |
VideoFrame::base64(t, text, media_type) |
A frame given as base64 text, sent as given, with its media type |
Video::new(id), .frame(...), .frames(...), .fps(...), .start(...), .end(...) |
A video: its id (unique across the request's images and videos), its frames with strictly increasing times, and optionally the most frames a second to read (above 0 and at most 4; the API's default is 2) and the window [start, end) on the frames' own times |
DecideRequest::video takes up to 4 videos, each a Video or a serde_json::Value in the wire form (Video::from(json!({...})), and VideoFrame::from for a frame), checked by the same rules. The helpers' CallOptions, decide_many and batch requests take videos as decide does; sessions do not. Frames always go inline: they are never uploaded or cached, and no frame is resized or re-encoded.
Before anything is sent the client checks the API's video rules. More than 4 videos is the API's Validation error with a too_long entry on ["body", "videos"]; every other fault is a BadRequest error with the API's message: a video without an id or with an id already used by an image or a video, a video without frames, a frame time that is negative or not after the one before it, a frame that is not JPEG, PNG or WebP by its media type, fps not above 0 and at most 4, start or end negative, and end not after start. How many frames a request may carry, their sizes and their pixels are the server's to judge. To turn a video file into frames, use the dn command line tool: dn --video dock.mp4.
A booster is a specialist model that reads a request's images before the decision. Its reading is added to what the model sees as evidence and comes back in the response. The model still answers every question: a booster never answers one, and a request without boosters is answered exactly as before. These boosters are available:
| Booster | What it reads | Options |
|---|---|---|
age |
How old the person in a picture is, with a range and a confidence; the largest face is read | None |
ai_generated |
How likely a picture was made by an image generator, the likeliest generator, and whether the file carries trusted Content Credentials and who signed them | None |
face_detection |
How many faces a picture shows and where: a box and a confidence per face | None |
face_recognition |
Whether a face matches one enrolled in a collection: per face a box, a score and the matched item's reference, with its name or label | collection (required): the id of one of your own collections, or celebrities; threshold (0 to 1, default 0.5): matches below it are not sent |
ocr |
The text a picture shows, all of it by default, or what a prompt asks for | prompt (at most 2,000 characters): "Text Recognition:" (the default), "Formula Recognition:", "Table Recognition:", or your own instruction or JSON schema |
bynn_depth |
Depth Vision: how far, in meters, what a picture shows is from the camera, straight ahead, in seven directions and on a grid | grid_size (2 to 16, default 8) |
knowledge |
Knowledge: no picture, but the request's state and questions; the passages of your own documents that apply, pinned rules first, cited by document, chunk and byte offsets. See Knowledge | collection (required): a knowledge base's id; scope, query, match, filter, top_k, max_tokens, pinned, min_score, include_text |
bynn_segment |
Segment Vision: what you name in words, found in a picture, each match's outline, box and centre and, with depth on, its distance and size in meters | prompt (required): one phrase or a list of 1 to 4, each at most 120 characters; max_instances (1 to 16, default 8), min_score (0 to 1, default 0.25), polygon_points (3 to 64, default 24), depth (default true) |
Depth Vision's and Segment Vision's distances and sizes are estimates from one picture, for decisions and planning, never a safety stop.
Name the boosters a request should run, as a list of names:
use decisionnode::{Image, Reading};
let d = dn
.decide(
DecideRequest::new()
.image(Image::upload_id("a1b2", "upl_..."))
.image(Image::url("c3d4", "https://files.example.com/c3d4.jpg"))
.boosters(["age", "ai_generated"])
.question("adult", truth("Is the person in the first picture an adult?"))
.question("synthetic", truth("Is the second picture AI-generated?")),
)
.await?;
for (name, evidence) in d.evidence.iter().flatten() {
println!("{name}: {:?} in {:?} ms", evidence.status, evidence.latency_ms);
for reading in &evidence.results {
match reading {
Reading::Age(r) => println!(" {}: {:?} years ({:?} to {:?})", r.image, r.age_years, r.low, r.high),
Reading::AiGenerated(r) => println!(" {}: generated {} by {}", r.image, r.generated, r.method),
other => println!(" {:?}", other.raw()),
}
}
}
println!("{:?} booster calls, {:?} tokens of readings", d.usage.boosters, d.usage.booster_tokens);A booster that needs a setting is a Booster: here ai_generated reads only the second picture, and the request fails if it cannot answer.
use decisionnode::Booster;
let request = DecideRequest::new()
.image(Image::upload_id("a1b2", "upl_..."))
.image(Image::url("c3d4", "https://files.example.com/c3d4.jpg"))
.booster("age")
.booster(Booster::new("ai_generated").image("c3d4").require(true))
.question("synthetic", truth("Is the second picture AI-generated?"));| Form | What is sent |
|---|---|
"age" (a name, or Booster::from("age")) |
"age": the booster reads every image of the request |
Booster::new(name), .image(id), .images(ids), .require(true), .options(...) |
{"name": name, "images"?: [...], "require"?: true, "options"?: {...}}, each field only when given; with none of them, the plain name |
A serde_json::Value in the wire form |
Sent as given |
Options, as listed above, are sent as given and checked by the API, which answers a bad one with its 400 api_usage_error:
let doors = Booster::new("bynn_segment").options(json!({"prompt": ["door", "doorway"], "max_instances": 4}));
let request = DecideRequest::new()
.image(Image::url("hall", "https://files.example.com/hall.jpg"))
.booster(doors)
.question("passable", truth("Is there an open door the robot can drive through?"));DecideRequest::booster and boosters take up to 4 boosters, each at most once, kept in your order; without any the body has no boosters key. The helpers' CallOptions, decide_many and batch requests take boosters as decide does; sessions do not. Names are plain strings, so a booster added later works with this version. The client checks nothing about boosters itself: the API answers a malformed list, an unknown name or a booster turned off for your workspace before any booster runs and before any charge.
d.evidence holds one Evidence per booster the request named, in its order (None when it named none): version, status (ok, timeout when it did not answer within its time budget, error, or skipped when none of the request's images is one it reads), latency_ms, billed_calls (the calls of that booster billed on this request), and results, one entry per picture; a picture the booster did not read (it timed out or failed, or could not decode the picture) is a Reading::Unknown holding only its image (and readable: false), status saying why. A reading is Reading::Age(AgeReading) (image, faces, and age_years, low, high, confidence, each None when faces is 0), Reading::AiGenerated(AiGeneratedReading) (image, generated, confidence, method: inference from the pixels or c2pa from the file's Content Credentials, top_generator with name and probability, None unless the picture is likely generated, c2pa_signer, and c2pa: "untrusted" when the file's Content Credentials validate but their signer is not trusted), Reading::FaceDetection(FaceDetectionReading) (image, faces, detections, each a bbox and a confidence), Reading::FaceRecognition(FaceRecognitionReading) (image, faces, collection_name, matches, each a FaceMatch with bbox, score, collection, collection_name, reference, name, label and metadata), Reading::Ocr(OcrReading) (image, text, truncated), Reading::BynnDepth(DepthReading) (image, ahead: meters to the nearest obstacle straight ahead with the floor left out, None when none is found; directions: 7 distances from left to right, each Option; ground: whether a floor was found and left out; grid_rows, grid_cols and grid, meters row by row from the top left; depth_min, depth_max, depth_median; width and height in pixels), Reading::BynnSegment(SegmentReading) (image, width, height, prompts as read, instances by score, each a SegmentInstance with label, score, bbox [x1, y1, x2, y2], polygon, area, centroid, and opening, depth and size_m when sent; depth_source and assumed_hfov_deg; coordinates are fractions of the picture from its top left), Reading::Knowledge(KnowledgeReading) (one result, not per picture, see Knowledge), or Reading::Unknown(UnknownReading) with its raw fields for a booster this version does not know or a reading with a field missing or of the wrong type, so the decision never fails on one. usage.booster_tokens is the readings' share of input_tokens and usage.boosters the booster calls billed; both are None for a request without boosters.
A booster call is one booster reading one picture with status ok, billed from your prepaid balance with the request's tokens; each booster's price is on the pricing page. Each entry's billed_calls times its booster's price is what the request's boosters cost. A booster that timed out or failed is free, and a refused request pays nothing for its boosters. Without require, a booster that fails leaves the decision to run without its reading; with require: true it fails the request with a 529 booster_unavailable, retried like any 529 (see Errors).
A model your workspace fine-tuned in the console is called by its name in model, by your workspace's keys only: decisionnode-flash@acme/fraud for the version deployed most recently, decisionnode-flash@acme/fraud:3 to pin version 3. The name is sent as given and d.model carries the pinned name that answered. models() lists your fine-tuned models beside the catalogue, with fine_tuned, base (the model it was trained from), adapter_version and status (deployed, or ready when trained but not deployed). A name that does not exist, is not deployed or is another workspace's is the API's 400 "Unknown model", a BadRequest error.
A request for decisionnode-moe that no ready worker can take at once is answered by decisionnode-flash instead of waiting. d.fallback says when that happened: Some(Fallback { served_model, requested_model }), read from the x-decisionnode-fallback and x-decisionnode-requested-model headers, and None on an ordinary answer; d.model is the model that answered. To have only the model you asked for answer, send .fallback(false): the request then gets the 529, an Overloaded error retried like any other.
let d = dn.decide(DecideRequest::new().model("decisionnode-moe").question("q", truth("?")).fallback(false)).await?;CallOptions::fallback, decide_many and batch requests take it as decide does; it is sent only when set.
On the hosted API every decision carries the result of the safety check, parsed from the x-decisionnode-safety header:
if let Some(safety) = &d.safety {
println!("{} {:?} {:?} {:?}", safety.checked, safety.harm, safety.self_harm, safety.flagged);
}flagged lists the categories above your key's thresholds. When the check did not run, checked is false and reason says why. A self-hosted server sends no header and safety is None. To also get the result in the response body, send the request header x-decisionnode-safety-body: true. Set it once on the client with ClientBuilder::header and every request carries it; the field then arrives in d.raw:
let dn = DecisionNode::builder().header("x-decisionnode-safety-body", "true").build()?;
let d = dn.decide(request).await?;
println!("{}", d.raw["safety"]);A knowledge base holds your own documents (a claims policy, house rules, a mission's special instructions). The knowledge booster gives a decision the passages of them that apply, as facts and rules to apply, and cites each one. A knowledge base is a collection of type text, under collections() like a collection of faces.
1. Create a knowledge base. A scope key is optional: it names the one metadata key whose value scopes a document to, say, one mission, and is fixed for the knowledge base's life.
use decisionnode::{knowledge, Document, DocumentOptions, NewCollection, Search};
let kb = dn.collections().create_with(&NewCollection::text("Claims policy").scope_key("mission")).await?;
let kb_id = kb["id"].as_str().unwrap_or_default();2. Add documents. Text and Markdown go inline. A file is read from a path, bytes (Document::bytes) or a reader (Document::reader, Document::async_reader): PDF, DOCX, PPTX, XLSX, HTML and pictures are uploaded and converted to text by the API; only the text is kept once the document is ready, and the original file is deleted. Ingestion is asynchronous: a document is queued, then processing, then ready or failed. wait(true) has the API hold the call up to 10 seconds and answer once the document is ready; wait_until_ready polls until a document, or the whole knowledge base, is done.
let rules = dn
.collections()
.add_document(
kb_id,
Document::text("# Receipts\n\nReject any claim over 500 SEK without a receipt."),
&DocumentOptions::new().title("Receipts rule").reference("rule-receipts").wait(true),
)
.await?;
let policy = dn
.collections()
.add_document(kb_id, Document::path("claims-policy-2026.pdf"), &DocumentOptions::new().reference("policy-2026"))
.await?;
let item = dn.collections().wait_until_ready(kb_id, policy["id"].as_str()).await?;
println!("{} {:?}", item["status"], item["error"]); // a failed document is returned, its `error` saying whyA text or Markdown file under the inline threshold (512 KiB) that is UTF-8 goes inline, titled by its file name without the extension unless you give a title; every other file is uploaded first. A file's type is the one you give (.content_type(...)), else its file name's extension, else its first bytes (PDF, PNG, JPEG, TIFF, WebP); DOCX, PPTX, XLSX and text need a type or a file name. replace_document replaces a document whole (a new version, the old one serving until it is ready), update_item changes its reference, label, metadata, pin and pin rank at once and without charge, or its title as a new version. get_item(kb_id, item_id, true) returns a document with its stored text, list_items and list_chunks page through documents and their chunks (list_items_with and an ItemQuery also find documents by a text in their title, file name or reference, q, and order them newest, oldest or by title, sort), delete_item deletes one and delete_failed every failed one, answering {"deleted": n}. upload_documents uploads up to 16 files ahead of time and returns their upload ids, for Document::upload_id within the hour.
3. Pin and scope. A pinned document is given to every decision that names the knowledge base, before any retrieved passage: pin a rule that must always apply. On a knowledge base with a scope key, a document whose metadata holds the key's value belongs to that scope; one without it is global.
let pin = DocumentOptions::new().title("Fraud rule").pinned(true).pin_rank(0);
dn.collections().add_document(kb_id, Document::text("Escalate any claim that names a previous claim number."), &pin).await?;
let brief = DocumentOptions::new().title("Mission M-42 brief").metadata(json!({"mission": "M-42"}));
dn.collections().add_document(kb_id, Document::path("m42-brief.md"), &brief).await?;4. Decide with knowledge. knowledge(collection) is the booster, with its options: scope (the scope key's value; without it only global documents are read), query (in place of the one built from the questions and the state), matching (sent as match), filter, top_k, max_tokens, pinned, min_score, include_text and require. Every option is sent as given and checked by the API.
use decisionnode::Reading;
let d = dn
.decide(
DecideRequest::new()
.state(json!({"claim": {"amount": "740.00", "receipt": false}, "mission": "M-42"}))
.booster(knowledge(kb_id).scope("M-42").require(true))
.question("approve", truth("Should the claim be approved under our policy?")),
)
.await?;
let evidence = d.evidence.as_ref().and_then(|e| e.get("knowledge"));
if let Some(Reading::Knowledge(k)) = evidence.and_then(|e| e.results.first()) {
for p in &k.passages {
println!("{} pinned={} {:?} > {:?} score={:?}", p.n, p.pinned, p.title, p.heading, p.score);
let doc = dn.collections().get_item(kb_id, &p.item, true).await?;
let text = doc["text"].as_str().unwrap_or_default();
println!(" {}", &text[p.byte_start as usize..p.byte_end as usize]);
}
}The reading is a KnowledgeReading: collection, collection_name, corpus, query, budget, scope, warnings (scope_not_named, over_budget, budget_floor, pinned_filtered), passages, omitted (the passages left out, by cause), pending_items, search and truncated. Each KnowledgePassage cites its document: item, reference, title, heading, chunk, and byte_start and byte_end, UTF-8 byte offsets into the document's stored text (get_item with include_text), the end exclusive, so a Rust &str slices by them directly. score is None for a pin. A knowledge call is USD 0.001, one a decision whatever it retrieves, billed only when the reading is ok; the passages' tokens are input tokens at the model's price (pricing).
5. Search. search returns the passages a decision would be given for a query, each with its text, without a decision. It is billed as one knowledge call and needs a full-scope key.
let found = dn.collections().search(kb_id, &Search::new("receipts for claims over 500 SEK").scope("M-42")).await?;
for p in &found.passages {
println!("{} {:?}", p.n, p.text);
}6. The errors you may meet. With require, a knowledge base with no document ready and none on its way is the 409 knowledge_collection_empty (an Other error with details()), and one whose documents are still being ingested is the 529 booster_unavailable, retried after its Retry-After; when ingestion has been held up for 15 minutes its details() say ingestion_delayed and it is returned at once, without retrying, so you decide what to do. Adding text faster than the workspace's ingestion rate is the 429 with details() reason ingest_queue_full or ingest_tokens_per_hour, retried after its Retry-After. A text over 512 KiB once normalised is the 413 item_too_large (RequestTooLarge). A title, reference, label, pin rank, metadata or format the API would refuse, an empty text, a file over 50 MB, an empty file and a file of another type are refused before anything is sent, with the API's own error.
7. Files and limits. Text (text/plain), Markdown (text/markdown), PDF, DOCX, PPTX, XLSX, HTML, PNG, JPEG, TIFF and WebP. A file is at most 50 MB, a PDF at most 100 pages, and a document's text at most 512 KiB after extraction. A title is 1 to 200 characters; a pin rank 0 to 1,000. Each document's PUT to storage waits up to 120 seconds (ClientBuilder::upload_timeout sets another), and a call with wait 20 seconds per attempt unless you set a timeout.
A session holds a fixed context on the server and answers a stream of frames over a WebSocket, each against the context and the latest frames. Sessions are behind the sessions feature, on by default.
use decisionnode::{choice, truth, Frame, SessionEvent, SessionOptions};
let session = dn
.open_session(
SessionOptions::new()
.instructions("You are the mission supervisor of a survey drone.")
.state(json!({"mission": "Survey the north field"}))
.question("mode", choice("Which flight mode now?", ["continue", "hold", "return", "land"]))
.question("abort", truth("Should the mission be aborted now?")),
)
.await?;
session.connect().await?;
session.send(Frame::new(json!({"battery": 0.31, "wind_mps": 11.4}))).await?;
match session.next_event().await? {
SessionEvent::Reply(reply) => println!("{:?}", reply.answers["mode"].choice()),
SessionEvent::FrameError(e) => println!("frame {:?}: {}", e.seq, e.message),
SessionEvent::Closed(closed) => println!("closed with {}", closed.code),
_ => {}
}
session.end().await?;send never waits for a reply, and seq counts from 1 unless a frame sets it. When frames arrive faster than they are answered, the latest wins: a reply lists the frames it superseded in dropped. Frame errors are values, never errors of the call. The key travels only in the handshake's Authorization header, and only to the API's own host. The socket opens with the client's HttpSettings, so a proxy, extra root certificates and a client certificate apply to it too. A frame's image follows the same inline or upload rule as a request's; a frame takes no URL.
supervise(options, feed, on_reply) keeps a session going for a long-lived feed: it sends each reading, passes every reply to on_reply, reopens on the close codes that call for it (at once after 4408 and 4410, otherwise after a backoff of 1 s to 30 s with jitter), connects again to the same session after 1013, and stops on any other code. A 429 session_limit_exceeded is never retried: it is an ErrorKind::SessionLimit error, a special case of RateLimit.
The Batch API answers up to 10,000 decide requests a batch for less than live calls. The price is each model's, never the batch's: a model's batch price is its live price times the batch_price_factor its models() entry lists (0.7 for DecisionNode MoE and 0.5 for DecisionNode Flash; a fine-tuned model's entry has none), and each result line's charge is in its usage (pricing). A batch has no set finishing time, so poll it; requests still unanswered 24 hours after it is finalized expire, unbilled. run_batch does the whole lifecycle: it checks every request, creates a draft, adds the requests in calls of at most 10 MiB, finalizes, polls every 60 seconds (run_batch_with sets the interval, at least 10 seconds, and a limit; a longer Retry-After from the server wins) and returns the ended batch with its results.
use decisionnode::{truth, BatchRequest, BatchStatus};
let requests = tickets.iter().map(|t| {
BatchRequest::new(t.id.clone(), DecideRequest::new().state(&t.text).question("refund", truth("Is a refund asked for?")))
});
let run = dn.run_batch(requests).await?;
for result in run.results {
let result = result?;
match (&result.status, &result.decision, &result.error) {
(BatchStatus::Http(200), Some(d), _) => println!("{}: {:?}", result.custom_id, d["refund"].truth()),
(_, _, Some(e)) => println!("{}: {} {e}", result.custom_id, e.kind()),
(status, _, _) => println!("{}: {status:?}", result.custom_id),
}
}batches() gives each call on its own: create, add, finalize, get, list, results, stream_results and cancel. results reads the whole file; stream_results reads it from the network line by line, a Stream of results (an Iterator in the blocking client) that holds one line at a time however large the file. Lines carry their custom_id, the model (the request's, else the client's) and the state ("" when none); their local images follow the inline or upload rule per line. A result line's error is a value with the kind and fields the same status gives on the live API. get, list, results and cancel are retried like every call; create, add and finalize only on 429 and 529, never after a timeout or a lost answer, so a batch is never created twice. A finalize answered 409 after an earlier one went through returns the batch.
usage() reads what each API key of your workspace used and cost: requests, input tokens, booster calls and the charge in USD, as totals or by hour, day or month, and split by model or by channel. It takes a key that may read usage (full access or manage device keys); a decisions-only key gets the API's 403 key_scope_denied.
use decisionnode::{UsagePeriod, UsageQuery};
// One key: the UTC month so far, by day.
let month = dn.usage().key("key_00000000000000000000000000000d01", &UsagePeriod::new().granularity("day")).await?;
println!("{}", month["totals"]["cost"]["usd"]);
// Every key of a customer over September, all pages.
let september = UsageQuery::new("2026-09-01T00:00:00Z", "2026-10-01T00:00:00Z").label("customer", "acme");
for item in dn.usage().all(&september).await? {
println!("{} {}", item["key"]["id"], item["totals"]["cost"]["usd"]);
}| Call | Sends | Returns |
|---|---|---|
key(key_id, &UsagePeriod) |
GET /v1/keys/{key_id}/usage, with the period's start, end, granularity and group_by when set (left out, the API reads the UTC month so far) |
The key's usage, as sent |
list(&UsageQuery) |
GET /v1/usage: start and end, and the filters set: label (by name, as name:value), device_id, key_id (up to 100), scope, status, include_empty, limit (1 to 1000) and cursor |
One page, as sent: period, as_of, items and next_cursor |
all(&UsageQuery) |
list's pages, following next_cursor until it is null |
Every page's items, in order |
A page can hold fewer keys than its limit, so read every page, or use all. The query rules the API checks (granularity is none, hour, day or month; group_by is model or channel; a label filter is name:value as labels are written; and the rules of device_id, the key ids, scope, status and limit) are refused before anything is sent, with the API's own BadRequest error and message. The period's rules (at most 400 days, hour over at most 7 days) are the API's.
A "manage device keys" key, kept on your server, makes and manages decisions-only keys for your devices: one key per device, each with its own labels, limits, daily spend cap and expiry, so a lost device is revoked alone. keys() calls /v1/keys; any other key kind gets the API's 403 key_scope_denied.
use decisionnode::{KeyQuery, KeyRevocation, KeyUpdate, NewKey};
let keys = dn.keys();
let made = keys.create(&NewKey::new("Door 7").device_id("door-7").label("site", "berlin").daily_spend_cap(5.0)).await?;
let secret = made["secret"].as_str().unwrap_or_default(); // shown this once: hand it to the device
let id = made["key"]["id"].as_str().unwrap_or_default();
keys.update(id, &KeyUpdate::new().name("Door 7, east").clear_daily_spend_cap()).await?;
let berlin = keys.all(&KeyQuery::new().label("site", "berlin")).await?;
keys.revoke(id).await?;
keys.revoke_by_labels(&KeyRevocation::new().label("site", "berlin")).await?;| Call | Sends | Returns |
|---|---|---|
create(&NewKey) |
POST /v1/keys with the fields set: name, device_id, labels, limits, daily_spend_cap, expires_at, may_search_collections |
{key, secret} as sent; the secret is never shown again |
list(&KeyQuery), all(&KeyQuery) |
GET /v1/keys with the filters set: label, device_id, status, limit (1 to 200) and cursor |
One page, {items, next_cursor}; all follows next_cursor and returns every page's items |
update(key_id, &KeyUpdate) |
PATCH /v1/keys/{key_id} with the fields set; clear_labels, clear_limits, clear_daily_spend_cap and clear_expires_at send null, which removes the setting |
The key as sent |
revoke(key_id) |
DELETE /v1/keys/{key_id} |
{id, revoked} |
revoke_by_labels(&KeyRevocation) |
POST /v1/keys/revoke with labels, and device_ids and dry_run when set |
{revoked, more} (or {would_revoke, more} for a dry run): call again while more is true |
create sends an Idempotency-Key, yours (NewKey::idempotency_key) or a new one, the same on every attempt, so a retried create makes one key and answers the same secret. The client never logs a secret. A name, device id, label or daily spend cap the API would refuse (the first one, as its 422 entry), a bulk revoke without a label or with more than 1,000 devices, a list filter the API would refuse, a limit outside 1 to 200, an Idempotency-Key that is not 1 to 255 printable ASCII characters and a change of nothing are refused before anything is sent, with the API's own error. A device's daily spend cap, once reached, answers its decisions with the 429 key_spend_cap_reached until 00:00 UTC; the client raises it at once instead of retrying.
Every failure is a decisionnode::Error, also exported under the name used across the DecisionNode SDKs, decisionnode::DecisionNodeError (a type alias, so either name catches every error). error.kind() says what happened:
| Status | ErrorKind |
Extra fields |
|---|---|---|
| 400 | BadRequest; ContextLimit when error_type is max_tokens_exceeded |
|
| 401 | Authentication |
|
| 402 | InsufficientCredit |
|
403 with refusal_error |
Refusal |
category() (harm or self_harm), safety() |
| 404 | NotFound |
|
| 413 | RequestTooLarge, never retried: the body is over 25 MiB |
|
| 422 | Validation |
entries() |
| 429 | RateLimit; SessionLimit when error_type is session_limit_exceeded |
retry_after() |
| 529 | Overloaded, also with error_type booster_unavailable when a booster the request requires failed |
retry_after(), boosters(), details() |
403 otherwise (booster_not_enabled, a suspended workspace) |
PermissionDenied |
|
| other 5xx | Server |
|
| an error status whose body is not JSON (a proxy's or the network edge's own page, or no body) | the kind of its status alone: a 400 is BadRequest, a 403 PermissionDenied, a 422 Validation with empty entries(), a 413 RequestTooLarge, a 408 Other; error_type() is None and the message is HTTP <status> <status text> |
status(), request_id(), reason(), retry_after() |
| 2xx whose body is not JSON | Server |
status(), request_id(), reason() |
2xx where check, classify or score gets no answer of the type it promises |
Server |
status(), request_id(), reason() |
| any other status: 3xx, 405, 409, 418, 408 once its retries are spent | Other, the base DecisionNodeError |
status() |
| no response | Connection, Timeout |
|
| a request the API would refuse, refused before sending | the API's own kind, BadRequest, RequestTooLarge for a body over 25 MiB, or Validation for what the API answers with a 422, with its error_type and message, without a status (is_local() is true) |
entries() for a Validation |
a storage PUT that failed |
Other, the base DecisionNodeError, with the storage's status, error_type upload_failed and the storage's error code in reason() |
|
| an argument refused before sending | Other, the base DecisionNodeError, without a status (is_argument() is true) |
A booster name that does not exist (booster_unknown), a booster that reads images on a request without any or an images entry that is not an image of the request (booster_input_mismatch), and a malformed boosters list (api_usage_error) are BadRequest errors. Every error of a request that named boosters carries boosters(): what each booster did, from the x-decisionnode-boosters header, by name in the request's order (status and latency_ms), so you can see which required booster failed. It is empty when the header is absent.
Only 200 to 299 is a success. Redirects are never followed, whatever transport settings you pass (see Configuration), so the key is never sent to another URL or over plain HTTP: a 3xx is an error carrying its status.
decide never fails over one odd answer: an answer with missing or wrongly typed fields comes back as Answer::Unknown. check, classify and score promise a typed answer, so when theirs is missing or malformed they return a Server error with the response's status, request id and reason() instead.
The error's own message, its Display, is the server's message exactly (detail.message, or detail when it is a string), or the client's description when there was no response; it is "" when the server's JSON body has none. An error whose body is not JSON (a page from a proxy or the network edge, or an empty body) is never read: its message is HTTP <status> <status text>, for example HTTP 413 Content Too Large, followed by (request id <id>) when the response has an x-request-id, and it is retried by its status alone (a 408 is retried, a 413 never). message() returns the same text, "" and never missing when there is none. Every error also has kind(), status(), error_type(), request_id() and reason(): the server's explanation in words, from the x-decisionnode-error header. {:?} prints all of them. A ContextLimit error is also a BadRequest and a SessionLimit error a RateLimit: error.is(ErrorKind::BadRequest) is true for both. Before sending, a call fails with Authentication when no key is set. Arguments the client refuses before sending anything are errors of the base kind, Other, without a status (error.is_argument() is true; there is no separate error kind for them): an empty question map, a value that cannot be serialised, an image file that cannot be read, a base URL that is not https, a timeout of 0 or below, a negative max_retries or inline_threshold, a decide_many_with concurrency below 1, or a run_batch_with poll interval below 10 seconds. A request that breaks one of the API's fixed rules fails before sending, with what the server would answer and no status (is_local()): the same kind, the same error_type, the documented message, and for a 422 a Validation error whose entries() are the server's list. That covers the 128-level nesting limit; question types, missing or mistyped fields, option and level counts, truth, number, point and box text, number grids, and a point's or box's image and units; a state of the wrong type; images (ids present, at most 64 characters and unique; format, 10 MB, 40 megapixels, an upload reference's media type), videos (ids, frames, frame times and media types, fps, start and end), more than 16 images or 4 videos (a too_long entry after the request's other entries, a local image listed by its id and media type without its bytes) and image URLs (https, 2,048 characters, no user information, port 443, no literal private address); a session's window and ttl_seconds; a session frame, which Session::send refuses with nothing written and no seq used; batches().list paging; and batch custom_id rules. After a models() call the client also checks the limits that list states for the model a request names, found by its name or by the alias or pinned version an entry states. The same checks run alone, sending nothing, with check_decide, check_session, check_batch, check_batch_add and Session::check_frame. decide_many and batch calls check every request first, so one invalid request fails the call and nothing is sent. Limits that depend on the model, such as tokens, are the server's to judge.
use decisionnode::ErrorKind;
match dn.decide(request).await {
Ok(d) => println!("{:?}", d["escalate"].truth()),
Err(e) if e.kind() == ErrorKind::RateLimit => println!("retry after {:?}", e.retry_after()),
Err(e) if e.kind() == ErrorKind::Refusal => println!("refused: {:?}", e.category()),
Err(e) => eprintln!("{} {:?}: {e} ({:?})", e.kind(), e.status(), e.reason()),
}Each attempt times out after 10 seconds, counted from sending the request to the last byte of the response body. A call is retried twice after the first attempt on 408, 429, every 5xx (529 included), timeouts and connection errors. The wait is retry-after-ms when the server sends it, else Retry-After in seconds or as an HTTP date, capped at 60 s; without either, an exponential backoff from 0.5 s with jitter, capped at 8 s. ClientBuilder::sleeper replaces the wait, for tests that record it instead of spending it. 400, 401, 402, 403, 404 and 422 are never retried. A decision changes nothing on the server, so a retry is always safe, with one exception:
A request naming the ocr booster (by its name or as Booster::new("ocr")) takes longer. Without a timeout of your own, each attempt waits 30 seconds (DEFAULT_OCR_TIMEOUT) instead of 10; a timeout you set with ClientBuilder::timeout is used as given, even a shorter one. Such a decision is never retried after a timeout, since the server finishes the attempt it was sent and may bill it: the call returns the Timeout error. Its other retries (408, 429, 5xx and connection errors) stay. If you set a timeout, make it at least 30 seconds for decisions that name ocr. Sessions, batches and decide_raw are not affected.
use std::time::Duration;
use decisionnode::{reqwest, DecisionNode, HttpSettings};
let dn = DecisionNode::builder()
.api_key("dn_...") // default: DECISIONNODE_API_KEY
.base_url("https://api.decisionnode.com") // default: DECISIONNODE_BASE_URL, else this URL
.model("decisionnode-moe") // default: DECISIONNODE_MODEL, else decisionnode-moe
.timeout(Duration::from_secs(10)) // per attempt, above 0; or .timeout_secs(10.0)
.max_retries(2) // 0 or more
.inline_threshold(524_288) // local images under this many bytes go inline
.upload_timeout(Duration::from_secs(60)) // per storage PUT attempt, apart from timeout
.header("x-decisionnode-safety-body", "true") // sent on every request
.http_settings(HttpSettings::new() // transport settings: proxy, root certificates, pool
.proxy(reqwest::Proxy::https("http://proxy.example:3128").expect("a valid proxy URL")))
.build()?; // .build_blocking()? for the blocking clientAn explicit setting wins over the environment, and the environment over the default. An empty string, for the key, the base URL or the model (also a request's own model), counts as not given. The base URL must be https; plain http is accepted only for localhost, 127.0.0.1 and ::1, so a key never travels unencrypted, and anything else fails build(). The SDK builds its own HTTP client and takes no client or reqwest::ClientBuilder of yours, because it could not show that such a client refuses redirects and keeps its TLS checks on. http_settings takes an HttpSettings instead: a proxy (https requests tunnel through it, so TLS stays end to end), extra root certificates or only your own (add_root_certificate, tls_built_in_root_certs), a client certificate (identity), connect_timeout, pool and TCP options, local_address, http1_only and resolve. They apply to every connection the client opens: API calls, uploads to storage and session sockets. None of them can turn on redirects or turn off certificate or hostname checks, and with an https base URL the client sends nothing over plain HTTP. Models are the family ids decisionnode-moe (the default) and decisionnode-flash (faster and lower cost), each meaning the current release of its family. Every answer names the pinned id that answered, decisionnode-<family>-<version>, in d.model; models() lists the family ids and the pinned ids your key may use, and a request pinned to one sends that id as model. A request picks its own with DecideRequest::model.
| Feature | Default | |
|---|---|---|
rustls-tls |
on | TLS with rustls |
native-tls |
off | TLS with the platform's library |
sessions |
on | Sessions over a WebSocket (open_session, supervise) |
blocking |
off | decisionnode::blocking::DecisionNode, with every call, a blocking Session and the Batch API |
The blocking client runs calls on a small runtime of its own: use the async client inside async code.
The SDK sends no telemetry and logs no state or questions. Its only added header is User-Agent: decisionnode-rust/<version>. With a log logger at debug level for decisionnode, it prints the method, path, status, request id and timing of each attempt.
cargo test --all-featuresThe integration tests run the DecisionNode SDK conformance suite (every request, response, error, behaviour and constant case, with both clients) against its mock server, from the suite DECISIONNODE_CONFORMANCE_DIR points at; they need python3 for the mock. Without the variable the conformance test fails rather than skipping. CI checks the suite out at one pinned commit, set in a single place at the top of .github/workflows/ci.yml, and pins every action to a commit SHA. This crate implements DecisionNode SDK specification version 1.4, exported as decisionnode::SPEC_VERSION, and passes conformance suite 1.27.
MIT. See LICENSE.