The Timestamp kind was a residual from an early research reference. It was byte-identical to String everywhere (length-prefixed UTF-8) and its only distinguishing behavior was a hand-rolled non-strict RFC 3339 check that the docs admitted was incomplete (Feb 31 passes, seconds range unchecked, no leap seconds). JSON-level timestamp validation is jsonschema's job (format: date-time on the validate_json path), not alktype's. Removes the AlkTypeKind::Timestamp variant, its to_bast_str/from_bast_str mapping, the builder's Schema::timestamp() constructor, the validate_timestamp/is_rfc3339_timestamp validator arms, and the materializer/reader/engine timestamp arms. Updates the meta-schema primitive enum (14 -> 13), the spec docs (bast-format.md, schema-layer.md, builder.md, validation.md, overview.md, data-access.md, README.md), and the kind-count references (19 -> 18). Verification: cargo test --release (407 pass), cargo clippy --all-targets -- -D warnings (clean), cargo doc --no-deps (clean), cargo build --target wasm32-unknown-unknown --release (clean).
19 KiB
status, last_updated
| status | last_updated |
|---|---|
| accepted | 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, D-BAST-007, and D-BAST-009, and recorded in ADR-VAL-SPLIT.
| 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::Keywordimplementations (~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_validatorpath —build_validatoris repurposed to build a standardjsonschema::Validatorfrom a consumer-provided JSON Schema (no custom keywords). Seebuild_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 and refined by ADR-VAL-SPLIT:
- Load time: Parse the BAST document into the typed tree, compute
the layout engine, and (optionally) build the standard
jsonschema::Validatorfor the JSON-validation path. This is theAlkTypeEngine::compileconstructor. - 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:
pub struct AlkTypeEngine {
layout: Layout, // packed or aligned (private enum)
json_validator: Option<jsonschema::Validator>, // 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 §"Mode Selection"). The
Layout enum is private — the engine exposes mode-appropriate
accessors instead:
impl AlkTypeEngine {
pub fn compile(
bast_doc: &Value,
root_name: &str,
mode: LayoutMode,
json_schema: Option<&Value>,
) -> Result<Self, AlkTypeError>;
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<SequentialReader>; // owned fresh reader (ADR-007)
pub fn read_field<'a>(&self, buffer: &'a [u8], field_path: &str)
-> Result<FieldValue<'a>, 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
§"Higher-level read/write".
build_validator
src/validation.rs exposes one function:
pub fn build_validator(schema: &Value) -> Result<jsonschema::Validator, AlkTypeError>;
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;
the variant shapes are unchanged under the pivot (D-BAST-009).
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 duringAlkTypeEngine::compile()or any BAST-walking path. Malformed BAST, missing$defs, unknownkindstring, dangling$ref, emptymapping, 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 ajsonschema::ValidationError<'static>. On thevalidate_jsonpath, this is thejsonschemacrate's own structured error. On thevalidate_bytespath, it is constructed viajsonschema::ValidationError::customfrom the BAST-native validator's path + reason string. The'staticlifetime is correct — the payload owns its data.
Field-path-carrying errors
Read/write and offset errors include the field path for debugging:
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:
- Parse the BAST document into the typed tree (
BastDoc::new). - Parse the root struct's
"endian"annotation. - Compute the layout (
LayoutBuilderfor packed,OffsetMapfor aligned). - If
json_schemaisSome, build the standardjsonschema::Validatorviavalidation::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):
- Materialize
Valuefrom bytes.materialize::materialize_packedormaterialize::materialize_alignedwalks the buffer against the BAST typed tree and the engine'sEndian, producing aserde_json::Valuetree. 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 returnsAlkTypeError::Access(with field paths) on read failures. - Validate the
Value. The materializedValueis passed tobast_validation::validate_value(&doc, &value), producingAlkTypeError::Validationon the first violated value-domain constraint.
Mode dispatch:
- Packed mode — materializes fields in declaration order.
- Aligned mode — uses the
OffsetMapto 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.
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 and
ADR-VAL-SPLIT.
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
Validatortrait. Two methods on one struct, not a trait abstraction. See ADR-010 §"Not aValidatortrait abstraction".
Relationship to Read/Write
Validation and data access are independent operations on the same data. The consumer can:
- Validate the bytes of a buffer to ensure it conforms to the BAST document's value constraints.
- Read fields from the binary buffer at computed offsets.
- 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 | 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 | AlkTypeError enum; load-time build, access-time check; field-path-carrying errors; jsonschema ValidationError wrapping |
Generalized validation — validate_bytes |
ADR-010 | Single-call binary-buffer validation (materialize Value from bytes, then validate); two methods on one struct, not a trait |
| BAST format | ADR-BAST | 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; see builder.md.
References
bast-format.md§Validation Model — the normative validation model- ADR-VAL-SPLIT — the two-validator decision
- ADR-004 — error handling and validation strategy
- ADR-010 —
validate_bytes(the collapsed two-step dance) - schema-layer.md — the BAST parser that the BAST-native validator walks
- data-access.md — read/write functions and the
materializer that produce the
Valuethe validator checks src/bast_validation.rs— the BAST-native validator implementationsrc/validation.rs— thebuild_validatorhelper