--- status: accepted last_updated: 2026-08-15 --- # alktype — Validation The validation layer: two validators for two input types, the `AlkTypeError` enum, the load-time-build / access-time-check strategy, and the `AlkTypeEngine` as the compiled form of a BAST document. ## The Validator Split BAST separates two concerns that the v0.1.0 format conflated, and in doing so reveals that the engine has **two distinct validation paths** with different inputs and guarantees. This is the validator split, decided in [D-BAST-006](../research/bast-pivot.md#d-bast-006-validate_bytes-validation-model), [D-BAST-007](../research/bast-pivot.md#d-bast-007-validate_json-validation-model), and [D-BAST-009](../research/bast-pivot.md#d-bast-009-alktypeerrorvalidation-payload-shape), and recorded in [ADR-VAL-SPLIT](decisions/val-split-two-validator-model.md). | Path | Input | Validator | Schema source | |------|-------|-----------|---------------| | `validate_bytes(&[u8])` | Raw bytes | BAST-native validator (`bast_validation`) | The BAST document (binary layout + value constraints) | | `validate_json(&Value)` | Parsed JSON `Value` | Standard `jsonschema::Validator` | A consumer-provided standard JSON Schema | ### `validate_bytes` — bytes in, BAST is the validator The materializer produces a `serde_json::Value` tree from bytes (walking the layout engine). By construction, this `Value` is *structurally correct*: all declared fields are present (the materializer iterates the field list), types are correct (`read_u32` produces `Value::Number`), bounds are checked (via `data_access::check_bounds`), UTF-8 is valid (via `from_utf8`), the discriminator is in the mapping, and the boolean byte is 0 or 1. What the materializer does NOT check — and what the BAST-native validator checks afterward — are **value-domain constraints expressed in the BAST document**. The BAST-native validator (`src/bast_validation.rs`) is a recursive walker over the BAST typed tree ([`crate::bast::BastDoc`]/[`BastType`]) that checks exactly these: | Constraint | Validator arm | |------------|---------------| | Integer range (Int8..Uint64) | `validate_int`/`validate_uint` with `as_i64`/`as_u64` + range check | | Int64/Uint64 (full range) | `validate_int64`/`validate_uint64` (JSON precision caveat per ADR-005) | | Float finiteness (Float32/64) | `validate_float` with `as_f64().is_finite()` | | String `maxLength` (byte length) | `check_string` reads the field-level `maxLength` | | Bytes `maxLength` (array length) | `check_bytes` accepts the `Value::Array` form (the materializer emits bytes as an array of u8) | | Enum index bounds | `validate_enum` checks `idx < values.len()` — **fixes the v0.1.0 dead constraint** | | Union variant dispatch | `validate_union` reads `__discriminator`, looks up the variant, recurses via `validate_typeref` | | Struct fields | `validate_struct` walks `fields`, requires each declared field present, recurses | | Array count | `validate_array` checks `arr.len() == count` and recurses per element | | Record values | `validate_record` recurses into each value's `values` type | | Boolean | `validate_bool` (materializer already rejects non-0/1 bytes) | No external JSON Schema is required for `validate_bytes`. The BAST document is the complete specification of the binary format — it describes both the layout (how to read) and the constraints (what values are valid). This is the "schema is the format" principle from ADR-001, now fully realized. An optional external JSON Schema can be layered on top for constraints BAST doesn't express (cross-field consistency, regex patterns on string content). This is additive, not load-bearing. ### `validate_json` — JSON in, JSON Schema is the validator The consumer provides a JSON `Value` (e.g., an incoming JSON-RPC request). The BAST document is irrelevant — BAST describes bytes, not JSON shape. The right validator for a JSON value is a standard `jsonschema::Validator` built from a standard JSON Schema document the consumer provides at `AlkTypeEngine::compile` time. This is the path alkcall uses for its `OperationSpec` JSON validation. No custom keywords; BAST is not involved. If no JSON Schema was supplied to `compile`, the JSON-validation methods return `AlkTypeError::Schema` (`validate_json`) or `false` (`is_valid_json`). ### `AlkTypeError::Validation` payload shape (D-BAST-009) The `validate_bytes` path no longer uses `jsonschema`, so its error payload is constructed via `jsonschema::ValidationError::custom` purely to keep the `Validation` variant's type unchanged. The rationale is consumer ergonomics on the *combined* path: consumers like alkcall use both `validate_json` (channel 0, JSON-RPC) and `validate_bytes` (binary channels) and handle `AlkTypeError::Validation` in one place. A single uniform payload type means one match arm covers both sources. The alternative (`Validation(String)`) would force `validate_json` to flatten its structured errors (instance path, schema path, keyword) to a `String` via `Display` — the more information-rich path loses data to accommodate the less rich one. That is the wrong direction. ### What is removed Under the BAST pivot, the v0.1.0 validation machinery is removed from the `validate_bytes` path: - All 19 `jsonschema::Keyword` implementations (~200 lines of validator factories) — replaced by the BAST-native validator (~250 lines, a flat match with no factories, no trait objects, no sub-validator pre-computation). - `inline_union_variant_refs()` — union variant refs are resolved lazily by the validator and materializer. - The custom-keyword `build_validator` path — `build_validator` is repurposed to build a *standard* `jsonschema::Validator` from a consumer-provided JSON Schema (no custom keywords). See [`build_validator`](#build_validator). The `jsonschema` crate **remains a direct dependency** for `validate_json` and for validating BAST documents against the BAST meta-schema. The only thing removed is the custom keyword integration path. The `validate_bytes` path no longer touches `jsonschema` — a small wasm binary-size win in addition to the architecture simplification. ## Validation Strategy The strategy is decided in [ADR-004](decisions/004-error-handling-validation-strategy.md) and refined by [ADR-VAL-SPLIT](decisions/val-split-two-validator-model.md): 1. **Load time:** Parse the BAST document into the typed tree, compute the layout engine, and (optionally) build the standard `jsonschema::Validator` for the JSON-validation path. This is the `AlkTypeEngine::compile` constructor. 2. **Access time:** Use the compiled engine for repeated read/write operations. Validation is opt-in per operation. ### The `AlkTypeEngine` struct The `AlkTypeEngine` is the compiled form of a BAST document. It supports both layout modes (ADR-002) via an internal `Layout` enum: ```rust pub struct AlkTypeEngine { layout: Layout, // packed or aligned (private enum) json_validator: Option, // None when no JSON Schema supplied endian: Endian, // parsed from the root struct's "endian" bast_doc: Value, // retained for sequential_reader/read_field root_name: String, // the selected $defs entry } // Private — the consumer selects via LayoutMode at compile time. enum Layout { Packed { builder: LayoutBuilder }, Aligned { offset_map: OffsetMap }, } ``` The consumer selects the mode at construction time via `LayoutMode` (see [layout-engine.md](layout-engine.md) §"Mode Selection"). The `Layout` enum is private — the engine exposes mode-appropriate accessors instead: ```rust impl AlkTypeEngine { pub fn compile( bast_doc: &Value, root_name: &str, mode: LayoutMode, json_schema: Option<&Value>, ) -> Result; pub fn mode(&self) -> LayoutMode; pub fn endian(&self) -> Endian; pub fn offset_map(&self) -> Option<&OffsetMap>; // Some in aligned mode pub fn layout_builder(&self) -> Option<&LayoutBuilder>; // Some in packed mode pub fn sequential_reader(&self) -> Option; // owned fresh reader (ADR-007) pub fn read_field<'a>(&self, buffer: &'a [u8], field_path: &str) -> Result, AlkTypeError>; // aligned mode pub fn write_field(&self, buffer: &mut [u8], field_path: &str, value: &FieldValue<'_>) -> Result<(), AlkTypeError>; // aligned mode pub fn validate_json(&self, instance: &Value) -> Result<(), AlkTypeError>; // D-BAST-007 pub fn is_valid_json(&self, instance: &Value) -> bool; // D-BAST-007 pub fn validate_bytes(&self, buffer: &[u8]) -> Result<(), AlkTypeError>; // D-BAST-006 } ``` `compile` takes `&Value` (not `&mut Value`) — BAST needs no in-place `normalize_refs`. `root_name` selects which `$defs` entry is the top-level type (D-BAST-001). `json_schema` is the optional consumer-provided standard JSON Schema for the `validate_json` path (D-BAST-007); pass `None` when JSON validation is not needed. The engine retains a clone of the BAST `Value` so `sequential_reader` and `read_field` can re-parse the typed tree on demand without lifetime entanglement with the caller's `Value`. The `Layout::Packed` variant stores only the `LayoutBuilder` (write-side). The `SequentialReader` (read-side) is not stored — it has mutable cursor state that the consumer owns, so `sequential_reader()` constructs a fresh reader on each call (ADR-007). The `read_field`/`write_field` methods on `AlkTypeEngine` are the aligned-mode data-access API — see [data-access.md](data-access.md) §"Higher-level read/write". ## `build_validator` `src/validation.rs` exposes one function: ```rust pub fn build_validator(schema: &Value) -> Result; ``` Under the pivot this is **repurposed** (D-BAST-007): it builds a *standard* `jsonschema::Validator` from a consumer-provided plain JSON Schema — no custom keywords, no BAST involvement. The engine calls it internally during `compile` when `json_schema` is `Some`. Consumers that only need a one-off validator may call `jsonschema::options().build(schema)` directly; `build_validator` exists so the engine's error mapping (`jsonschema` build error → `AlkTypeError::Schema`) is reused. The v0.1.0 custom-keyword `build_validator` (registered 19 `with_keyword(...)` factories) is removed. ## AlkTypeError A single `AlkTypeError` enum covers all error conditions across the engine's phases (schema parsing, offset computation, read/write) plus validation. Decided in [ADR-004](decisions/004-error-handling-validation-strategy.md); the variant shapes are unchanged under the pivot (D-BAST-009). ```rust pub enum AlkTypeError { /// Schema parsing errors (malformed BAST, dangling $ref, unknown kind). Schema(String), /// Offset computation errors (field not found, unsupported type). Offset { field_path: String, reason: String }, /// Read/write errors (buffer too short, invalid UTF-8, value out of range). Access { field_path: String, reason: String }, /// Validation errors (both paths — D-BAST-009 uniform payload). Validation(jsonschema::ValidationError<'static>), } ``` - **`Schema`** — for errors during `AlkTypeEngine::compile()` or any BAST-walking path. Malformed BAST, missing `$defs`, unknown `kind` string, dangling `$ref`, empty `mapping`, etc. - **`Offset`** — for errors during offset computation. Field not found in the BAST tree, type not supported for offset computation. Carries the field path. - **`Access`** — for errors during read/write. Buffer too short, invalid UTF-8 in a string field, value out of range for the target type. Carries the field path. - **`Validation`** — wraps a `jsonschema::ValidationError<'static>`. On the `validate_json` path, this is the `jsonschema` crate's own structured error. On the `validate_bytes` path, it is constructed via `jsonschema::ValidationError::custom` from the BAST-native validator's path + reason string. The `'static` lifetime is correct — the payload owns its data. ### Field-path-carrying errors Read/write and offset errors include the field path for debugging: ```rust Err(AlkTypeError::Access { field_path: "header.version".to_string(), reason: "buffer too short: need 4 bytes at offset 12, have 2".to_string(), }) ``` This makes debugging binary format issues tractable — the error tells you exactly which field failed and why. ## Validation Timing ### Load time: `AlkTypeEngine::compile()` The expensive work happens once at schema load time: 1. Parse the BAST document into the typed tree (`BastDoc::new`). 2. Parse the root struct's `"endian"` annotation. 3. Compute the layout (`LayoutBuilder` for packed, `OffsetMap` for aligned). 4. If `json_schema` is `Some`, build the standard `jsonschema::Validator` via `validation::build_validator`. The result is an `AlkTypeEngine` that can be used for repeated operations. The BAST-native validator is not pre-built — it is a recursive walker that runs on the materialized `Value` at access time, re-using the `BastDoc` (re-parsed on demand from the retained `bast_doc`). ### Access time: `engine.validate_bytes(&[u8])` For binary-layout schemas, `validate_bytes` runs the two phases in sequence (D-BAST-006): 1. **Materialize `Value` from bytes.** `materialize::materialize_packed` or `materialize::materialize_aligned` walks the buffer against the BAST typed tree and the engine's `Endian`, producing a `serde_json::Value` tree. Composites are recursed into (`Struct` → object of field values; `Array` → array of element values; `Union` → dispatch then recurse; `Record` → object of key/value entries). The read phase reuses the existing data-access functions and returns `AlkTypeError::Access` (with field paths) on read failures. 2. **Validate the `Value`.** The materialized `Value` is passed to `bast_validation::validate_value(&doc, &value)`, producing `AlkTypeError::Validation` on the first violated value-domain constraint. Mode dispatch: - **Packed mode** — materializes fields in declaration order. - **Aligned mode** — uses the `OffsetMap` to read fields at their computed offsets. Both modes produce the same `Value` form; the BAST-native validator is mode-agnostic. ### Access time: `engine.validate_json(&Value)` / `engine.is_valid_json(&Value)` Validation is opt-in per operation. The consumer calls `engine.validate_json(instance)` when validation is desired, or `engine.is_valid_json(instance)` for a boolean check. The `jsonschema::Validator` is already compiled — these are fast checks against the compiled validator. ```rust pub fn validate_json(&self, instance: &Value) -> Result<(), AlkTypeError>; pub fn is_valid_json(&self, instance: &Value) -> bool; ``` The argument is a `serde_json::Value` (the JSON representation of the data), not a raw byte buffer. `validate_json` validates against the consumer-provided JSON Schema supplied at `compile` time (D-BAST-007); the BAST document is not involved. If no JSON Schema was supplied, `validate_json` returns `AlkTypeError::Schema` and `is_valid_json` returns `false`. High-throughput paths can skip validation. Security-sensitive paths (parsing incoming frames from untrusted peers) can validate every frame. The choice is the consumer's. ### When to use which entry point | Entry point | Schema form | Input form | When | |-------------|--------------|------------|------| | `validate_json(&Value)` | Consumer-provided standard JSON Schema | Already-parsed `serde_json::Value` | Call's JSON payloads (`OperationSpec.input_schema`); anything off `serde_json::from_slice` / `from_str` | | `validate_bytes(&[u8])` | BAST document (binary layout) | Raw `&[u8]` buffer | Channels' 8-byte chunk header; future binary call frames; SFTP packet buffers; metatensor index structs | `validate_bytes` requires the engine's root type to be a struct (the layout engine enforces this) — it materializes `Value` via the layout engine, which needs binary-layout semantics. For pure JSON payloads, the consumer uses `serde_json::from_slice` then `validate_json`. See [ADR-010](decisions/010-generalized-validation-validate-bytes.md) and [ADR-VAL-SPLIT](decisions/val-split-two-validator-model.md). #### What `validate_bytes` is not - **Not framing-aware.** It validates the bytes of *one* schema instance. It does not strip length prefixes, parse `[length: u32][payload]` framing, or handle multiple frames in a buffer. That's the consumer's job. alktype validates what one schema describes; it does not parse the wire envelope around it. - **Not a `Validator` trait.** Two methods on one struct, not a trait abstraction. See [ADR-010](decisions/010-generalized-validation-validate-bytes.md) §"Not a `Validator` trait abstraction". ## Relationship to Read/Write Validation and data access are independent operations on the same data. The consumer can: 1. Validate the bytes of a buffer to ensure it conforms to the BAST document's value constraints. 2. Read fields from the binary buffer at computed offsets. 3. Both — validate first, then read (defense in depth). The engine does not couple validation and access. A consumer that trusts its data source can skip validation and go straight to read/write. A consumer that parses untrusted input can validate first, then access the binary buffer. ## Design Decisions | Decision | ADR | Summary | |----------|-----|---------| | Two-validator model (BAST-native + standard jsonschema) | [ADR-VAL-SPLIT](decisions/val-split-two-validator-model.md) | `validate_bytes` uses the BAST-native validator; `validate_json` uses a standard `jsonschema::Validator` from a consumer-provided JSON Schema; D-BAST-006/007/009 | | Error handling and validation strategy | [ADR-004](decisions/004-error-handling-validation-strategy.md) | `AlkTypeError` enum; load-time build, access-time check; field-path-carrying errors; jsonschema `ValidationError` wrapping | | Generalized validation — `validate_bytes` | [ADR-010](decisions/010-generalized-validation-validate-bytes.md) | Single-call binary-buffer validation (materialize `Value` from bytes, then validate); two methods on one struct, not a trait | | BAST format | [ADR-BAST](decisions/bast-bast-format.md) | The BAST document is the complete binary-format spec (layout + value constraints) | ## Open Questions None specific to validation. The three alktype OQs (OQ-001, OQ-002, OQ-003) are about layout, platform support, and schema construction — not validation. OQ-003 is resolved by [ADR-009](decisions/009-builder-api.md); see [builder.md](builder.md). ## References - [`bast-format.md` §Validation Model](bast-format.md#validation-model) — the normative validation model - [ADR-VAL-SPLIT](decisions/val-split-two-validator-model.md) — the two-validator decision - [ADR-004](decisions/004-error-handling-validation-strategy.md) — error handling and validation strategy - [ADR-010](decisions/010-generalized-validation-validate-bytes.md) — `validate_bytes` (the collapsed two-step dance) - [schema-layer.md](schema-layer.md) — the BAST parser that the BAST-native validator walks - [data-access.md](data-access.md) — read/write functions and the materializer that produce the `Value` the validator checks - [`src/bast_validation.rs`](../../src/bast_validation.rs) — the BAST-native validator implementation - [`src/validation.rs`](../../src/validation.rs) — the `build_validator` helper