Resolve v0.1.0 open questions and fix production-readiness issues

POC: /workspace/alktype-builder-poc/ (18/18 tests pass, findings in
docs/research/alktype-builder-poc/findings.md). Round 2 adds the SFTP
Packet validate_bytes tests (7 new: valid Init/Read/Write/Status,
short buffer, unknown discriminator, over-maxLength Bytes).

Open questions resolved (OQ-004 through OQ-008):
- OQ-004: Discriminator::Field name is String (already implemented;
  docs updated to mark resolved)
- OQ-005: Both union discriminator kinds return the same shape:
  {__discriminator, ...variant-fields}. Field-name path also had a
  real offset bug (returned start, not end) - fixed.
- OQ-006: builder.md Example 3 now wraps the Union in a
  Schema::struct_().field("payload", ...) and merges $defs via
  Definitions::merge_into (matches the engine's AlkType:Struct-at-root
  constraint and the SFTP wire shape)
- OQ-007: Bytes materialization is array-of-u8 (Value::Array of
  Value::Number, one entry per byte 0..=255). BytesValidator accepts
  both Value::String (validate_json) and Value::Array (validate_bytes).
  maxLength = max byte count. Replaces the lossy from_utf8_lossy path
  that corrupted non-UTF-8 bytes and broke maxLength semantics.
- OQ-008 (new): UnionValidator now dispatches to variant schemas via
  sub-validators built at factory time. AlkTypeEngine::compile calls
  schema::inline_union_variant_refs before build_validator to inline
  $refs in union mapping entries (necessary because union_factory
  receives the union node, but $defs live at the schema root).

Production-readiness fixes in src/ (no stubs/hedges in a published crate):
- materialize.rs: Record stub -> full count-prefixed key/value pair
  implementation per schema-layer.md TRecord
- materialize.rs: root_of() was broken (returned the current node, not
  the schema root) -> root schema threaded through every recursive call
  so resolve_ref_or_inline can resolve $refs for nested composites
- builder.rs: LengthPrefixed encoding setter was a no-op when the
  keyword was already in object form -> complete the branch (updates
  the encoding entry in place for both LengthPrefixed and OffsetIndirect)
- builder.rs, engine.rs: POC-referencing comments cleaned up; the
  round-trip test's or_else fallback (papering over write_field being
  aligned-only) replaced with direct byte writes

Documentation:
- builder.md: Example 3 updated; Discriminator::Field spec shows String;
  Open Questions section updated (OQ-004 resolved)
- validation.md: AlkType:Bytes and AlkType:Union validator descriptions
  updated for array-of-u8 form and variant dispatch
- open-questions.md: OQ-004/005/006/007/008 marked resolved; new
  Validation theme entries
- questions/004-008: individual OQ files updated with resolutions
- findings.md: round 2 results documented

Verification:
- cargo test: 369 -> 391 tests pass (22 new: 14 materialize, 5
  inline_union_variant_refs, 3 validation/builder)
- cargo clippy --all-targets: clean
- POC: 11 -> 18 tests (7 new SFTP Packet tests); all pass
This commit is contained in:
2026-08-11 07:28:08 +00:00
parent c6893eece8
commit c0217d91a8
15 changed files with 1553 additions and 493 deletions

View File

@@ -359,7 +359,7 @@ pub enum Discriminator {
/// Field-name discriminator (ADR-003 §4 Kind B).
/// `name` is the field holding the discriminator value.
Field {
name: &str, // or String — see "Open question" below
name: String,
},
}
```
@@ -536,8 +536,15 @@ let read_file_input = Schema::object()
### Example 3: SFTP `Packet` union (binary layout, byte discriminator)
The SFTP wire shape is `[type:u8][payload-struct]` — a struct with a
union payload field. The engine requires `AlkType:Struct` at the top
level (`OffsetMap::compute` / `SequentialReader::new` both enforce
this; a `Union` is a field type within a struct, not a top-level
schema). The builder constructs the union wrapped in a struct, and
`$defs` are merged into the top-level schema so `$ref`s resolve:
```rust
use alktype::{Schema, Discriminator, AlkTypeKind};
use alktype::{Definitions, Discriminator, AlkTypeKind, Schema};
let mut defs = Definitions::new();
defs.define("Init", Schema::struct_().field("version", Schema::uint32()));
@@ -546,16 +553,26 @@ defs.define("Read", Schema::struct_().field("handle", Schema::bytes()).field("o
defs.define("Write", Schema::struct_().field("handle", Schema::bytes()).field("offset", Schema::uint64()).field("data", Schema::bytes()));
defs.define("Status", Schema::struct_().field("code", Schema::uint32()).field("message", Schema::string()));
let packet = Schema::union_(Discriminator::Byte {
offset: 0,
disc_type: AlkTypeKind::Uint8,
})
.mapping("1", Schema::ref_def("Init"))
.mapping("3", Schema::ref_def("Open"))
.mapping("5", Schema::ref_def("Read"))
.mapping("6", Schema::ref_def("Write"))
.mapping("101", Schema::ref_def("Status"))
// A "Packet" is a struct with one field — the union. This mirrors
// SFTP's wire shape: [type:u8][payload-struct].
let mut packet = Schema::struct_()
.field(
"payload",
Schema::union_(Discriminator::Byte {
offset: 0,
disc_type: AlkTypeKind::Uint8,
})
.mapping("1", Schema::ref_def("Init"))
.mapping("3", Schema::ref_def("Open"))
.mapping("5", Schema::ref_def("Read"))
.mapping("6", Schema::ref_def("Write"))
.mapping("101", Schema::ref_def("Status")),
)
.build();
// Merge $defs into the top-level schema so $refs resolve at compile time.
defs.merge_into(&mut packet);
// Feed to AlkTypeEngine::compile(&mut packet, LayoutMode::Packed)
// then validate incoming frames via engine.validate_bytes(&frame).
```
### Example 4: OperationSpec error schemas (named `$defs`)
@@ -606,12 +623,13 @@ let op_errors = vec![
## Open Questions
- **OQ-004** (open): `Discriminator::Field` name type — `&str` or
`String`? The builder consumes `Self` on each setter, so the
discriminator's `name` needs to outlive the `Schema` it's embedded
in. `&str` borrows; `String` owns. Tracked in
[open-questions.md](open-questions.md); full file:
[OQ-004](questions/004-discriminator-field-name-type.md).
None specific to the builder. OQ-003 (the original "should we build a
builder API") is resolved by this spec / ADR-009. OQ-004
(`Discriminator::Field` name type — `&str` or `String`) is resolved:
`String`, for ownership simplicity (the builder consumes `Self` on
setters; `&str` would require a lifetime parameter on `Discriminator`
and transitively on `Schema::union_`). See
[open-questions.md](open-questions.md).
## References

View File

@@ -62,15 +62,16 @@ Door type is separate from whether a decision is made. A two-way door is a decis
| OQ | Title | Status | Door | Pri |
|----|-------|--------|------|-----|
| [OQ-003](questions/003-builder-api-for-schema-construction.md) | Builder API for Schema Construction | resolved (ADR-009) | two | med |
| [OQ-004](questions/004-discriminator-field-name-type.md) | `Discriminator::Field` name — `&str` or `String` | open | two | low |
| [OQ-006](questions/006-builder-spec-example-3-wrap-union.md) | Builder spec Example 3 — wrap `Union` in a `Struct` | open | two | low |
| [OQ-004](questions/004-discriminator-field-name-type.md) | `Discriminator::Field` name — `&str` or `String` | resolved | two | low |
| [OQ-006](questions/006-builder-spec-example-3-wrap-union.md) | Builder spec Example 3 — wrap `Union` in a `Struct` | resolved | two | low |
### Validation
| OQ | Title | Status | Door | Pri |
|----|-------|--------|------|-----|
| [OQ-005](questions/005-union-materialization-shape.md) | `Union` materialization shape — byte-offset vs field-name consistency | open | two | med |
| [OQ-007](questions/007-bytes-materialization-lossy-utf8.md) | `Bytes` materialization — lossy UTF-8 conversion | open | one | med |
| [OQ-005](questions/005-union-materialization-shape.md) | `Union` materialization shape — byte-offset vs field-name consistency | resolved | two | med |
| [OQ-007](questions/007-bytes-materialization-lossy-utf8.md) | `Bytes` materialization — lossy UTF-8 conversion | resolved | one | med |
| [OQ-008](questions/008-unionvalidator-variant-dispatch.md) | `UnionValidator` variant dispatch | resolved | one | med |
## Open
@@ -79,54 +80,59 @@ that need to be worked through, not waited on. Each has a concrete
investigation target. This section exists so "what's currently on the
architect's desk" is answerable at a glance.
### OQ-004: `Discriminator::Field` name — `&str` or `String`
> **Note**: All v0.1.0 open questions (OQ-004, OQ-005, OQ-006, OQ-007,
> OQ-008) were resolved during the v0.1.0 POC round 2 (SFTP Packet
> `validate_bytes` POC). The resolutions are summarized below for
> traceability; see each OQ's full file for the decision rationale.
> The currently-parked OQs are OQ-001 and OQ-002 (see Deferred / Blocked
> below).
- **Investigation target**: Decide whether `Discriminator::Field { name }`
should borrow (`&str`) or own (`String`). The builder consumes `Self`
on setters, so `&str` would require a lifetime parameter on
`Discriminator` (and transitively `Schema::union_`). Likely `String`
for ownership simplicity.
- **Priority**: low (doesn't block the chunk header POC; must be
resolved before the SFTP Packet POC's field-name discriminator path)
### OQ-004: `Discriminator::Field` name — `&str` or `String` — RESOLVED
- **Status**: resolved. `String`, for ownership simplicity (the builder
consumes `Self` on setters; `&str` would require a lifetime parameter
on `Discriminator` and transitively on `Schema::union_`). Implemented
in `src/builder.rs` since the initial v0.1.0 builder implementation.
- **Full file**: [OQ-004](questions/004-discriminator-field-name-type.md)
### OQ-005: `Union` materialization shape — byte-offset vs field-name consistency
### OQ-005: `Union` materialization shape — RESOLVED
- **Investigation target**: The current `materialize_union_packed`
returns inconsistent shapes for the two discriminator kinds (byte-
offset produces `{ "__discriminator": <u32>, ...fields }`; field-name
produces the struct fields directly). Decide on one consistent shape.
Work through the SFTP Packet `validate_bytes` example to surface what
the `jsonschema` validator needs to see for a `AlkType:Union` field.
- **Priority**: medium (blocks the SFTP Packet `validate_bytes` POC,
the natural next round)
- **Status**: resolved. Both discriminator kinds return
`{ "__discriminator": <value>, ...variant-fields }`. The field-name
path also had a real offset bug (returned start offset, not end) —
fixed. Implemented in `src/materialize.rs`.
- **Full file**: [OQ-005](questions/005-union-materialization-shape.md)
### OQ-006: Builder spec Example 3 — wrap `Union` in a `Struct`
### OQ-006: Builder spec Example 3 — wrap `Union` in a `Struct` — RESOLVED
- **Investigation target**: Update builder.md Example 3 (SFTP Packet)
to wrap the `Union` in a `Struct` — the engine requires
`AlkType:Struct` at the top level (ADR-002), and the wrapped shape
matches SFTP's actual wire format (`[length][type][payload]`). The
POC already uses the wrapped shape; the spec example just hasn't
been updated.
- **Priority**: low (documentation fix; doesn't block any implementation)
- **Status**: resolved. [builder.md](builder.md) Example 3 now wraps
the `Union` in a `Schema::struct_().field("payload", ...)` and merges
`$defs` via `Definitions::merge_into`. Matches the realistic SFTP
wire shape and the engine's `AlkType:Struct`-at-root constraint.
- **Full file**: [OQ-006](questions/006-builder-spec-example-3-wrap-union.md)
### OQ-007: `Bytes` materialization — lossy UTF-8 conversion
### OQ-007: `Bytes` materialization — lossy UTF-8 conversion — RESOLVED
- **Investigation target**: The current materializer uses
`String::from_utf8_lossy` for `AlkType:Bytes` fields, which corrupts
non-UTF-8 bytes. Decide on a round-trippable form (base64 with a
`format` constraint, or `Value::Array` of u8 with a validator that
accepts arrays). Work through SFTP's binary `handle`/`data` fields
to surface what `validate_bytes` callers actually need.
- **Priority**: medium (blocks the SFTP use case for `validate_bytes`;
does not block the chunk header or call input schema)
- **Door type**: one-way (changing what the validator sees is a
semantic break for consumers that depend on the shape)
- **Status**: resolved. Array of u8: the materializer produces
`Value::Array` of `Value::Number` (one entry per byte, 0..=255) for
`AlkType:Bytes` fields. The `BytesValidator` accepts both
`Value::String` (for `validate_json`) and `Value::Array` (for
`validate_bytes`). `maxLength` = max byte count. Implemented in
`src/materialize.rs` and `src/validation.rs`.
- **Full file**: [OQ-007](questions/007-bytes-materialization-lossy-utf8.md)
### OQ-008: `UnionValidator` variant dispatch — RESOLVED
- **Status**: resolved. `UnionValidator` now builds a sub-validator for
each variant at factory time and dispatches on `__discriminator` at
validation time. `AlkTypeEngine::compile` calls
`schema::inline_union_variant_refs` before `build_validator` to inline
`$ref`s in union `mapping` entries (necessary because the
`union_factory` receives the union node, but `$defs` live at the
schema root). Implemented in `src/validation.rs`, `src/schema.rs`,
and `src/engine.rs`.
- **Full file**: [OQ-008](questions/008-unionvalidator-variant-dispatch.md)
## Deferred / Blocked
The safe-exit visibility surface. These questions are parked because the

View File

@@ -2,26 +2,18 @@
- **Origin**: [../builder.md](../builder.md) §"Open Questions" (raised
during ADR-009 spec drafting)
- **Status**: open
- **Status**: resolved
- **Door type**: Two-way (the field is internal to the `Discriminator`
enum; changing the type is a local refactor with no schema-level impact)
- **Priority**: low
- **Impacts**: Blocks finalizing the `Discriminator::Field` variant's
signature in `src/builder.rs`. Does NOT block any current POC — the
chunk header uses `Discriminator::Byte`, not `Discriminator::Field`.
The field-name discriminator is exercised by the SFTP Packet POC
(next round), so this must be resolved before that POC.
- **Investigation target**: Decide whether the `name` field of
`Discriminator::Field` should be `&str` (borrows) or `String` (owns).
The builder consumes `Self` on each setter (`.field()`, `.mapping()`,
etc.), so the discriminator's `name` needs to outlive the `Schema`
it's embedded in. `&str` would require a lifetime parameter on
`Discriminator` (and transitively on `Schema::union_`); `String`
owns its content and keeps `Discriminator` `'static`. The likely
resolution is `String` for ownership simplicity — the builder owns
its content until `.build()` produces `Value`, and `String` avoids
lifetime pollution across the builder API.
- **Resolution**: Not yet decided. Confirm during the next POC round
(SFTP Packet) when `Discriminator::Field` is first exercised.
- **Resolution**: `String`. The builder consumes `Self` on each setter
(`.field()`, `.mapping()`, etc.), so `&str` would require a lifetime
parameter on `Discriminator` (and transitively on `Schema::union_`),
polluting the builder API. `String` owns its content, keeps
`Discriminator` `'static`, and matches the rest of the builder's
ownership model (the builder owns its content until `.build()`
produces `Value`). Implemented in `src/builder.rs` since the initial
v0.1.0 builder implementation; confirmed correct during the SFTP
Packet POC (round 2) which first exercises `Discriminator::Field`.
- **Cross-references**: [ADR-009](../decisions/009-builder-api.md),
[builder.md](../builder.md)

View File

@@ -2,39 +2,27 @@
- **Origin**: [../../research/alktype-builder-poc/findings.md](../../research/alktype-builder-poc/findings.md)
§"Open Questions surfaced" (raised during the v0.1.0 POC)
- **Status**: open
- **Status**: resolved
- **Door type**: Two-way (the materializer is internal; the shape it
produces is consumed only by the existing `jsonschema` validator,
which accepts arbitrary objects — changing the shape is a local
refactor)
- **Priority**: medium
- **Impacts**: Blocks the SFTP Packet `validate_bytes` POC (next round).
The current `materialize_union_packed` returns inconsistent shapes:
byte-offset discriminators produce `{ "__discriminator": <u32>,
...variant-fields }`; field-name discriminators produce the struct
fields directly (the discriminator is just another field). For
`validate_bytes` callers, the shape matters for how the validator
dispatches on the variant. Does NOT block the chunk header or call
input schema (no unions in either).
- **Investigation target**: Decide on one consistent shape for
materialized unions. Two candidates:
1. **Both return a tagged object**: `{ "__discriminator": <value>,
...variant-fields }` for both discriminator kinds. The validator
sees the discriminator value and the variant fields in one
object. This is what the byte-offset path already does.
2. **Both return the variant struct directly** (no `__discriminator`
wrapper): the discriminator is consumed during dispatch and not
included in the materialized `Value`. The field-name path
approximates this today.
Option 1 is more uniform and lets the validator re-check the
discriminator; option 2 is cleaner but loses the discriminator in
the `Value` tree. Resolve by working through the SFTP Packet
`validate_bytes` example — what does the `jsonschema` validator need
to see for a `AlkType:Union` field?
- **Resolution**: Not yet decided. Work through the SFTP Packet POC
to surface the validator's requirements.
- **Resolution**: Both discriminator kinds return the same shape:
`{ "__discriminator": <value>, ...variant-fields }`. The
`__discriminator` entry carries the mapping key (stringified
discriminator value for byte-offset, string value for field-name).
For field-name discriminators, the original typed discriminator value
is also preserved under the discriminator field's name (it's a
regular field in the struct). If the variant materializes as a
non-object (a leaf kind), it is nested under `"__variant"`.
Implemented in `src/materialize.rs` (`materialize_union_packed`).
Additionally, the field-name path had a real correctness bug
(`Ok((value, offset))` returned the start offset, not the end —
subsequent fields after a field-name union would read from the wrong
position). This is now fixed: the path returns the new offset after
the variant struct.
- **Cross-references**: [ADR-010](../decisions/010-generalized-validation-validate-bytes.md),
`src/materialize.rs` (`materialize_union_packed`),
[../../research/alktype-builder-poc/findings.md](../../research/alktype-builder-poc/findings.md)

View File

@@ -2,34 +2,17 @@
- **Origin**: [../../research/alktype-builder-poc/findings.md](../../research/alktype-builder-poc/findings.md)
§"Findings" #1 (raised during the v0.1.0 POC)
- **Status**: open
- **Status**: resolved
- **Door type**: Two-way (documentation fix — the builder already
supports both shapes; the spec example just shows the wrong one)
- **Priority**: low
- **Impacts**: Blocks nothing functional. The builder spec's Example 3
(SFTP Packet) currently shows a top-level `Schema::union_(...)`,
which won't compile via `AlkTypeEngine::compile` — the engine
requires `AlkType:Struct` at the top level (existing constraint from
`OffsetMap::compute` / `SequentialReader::new`, ADR-002). The POC
worked around this by wrapping the union in a
`Schema::struct_().field("payload", Schema::union_(...))`, which is
the realistic wire shape anyway (SFTP's `[length:u32][type:u8][payload]`
is a struct with a union payload field). The spec example should
match the realistic shape.
- **Investigation target**: Update [../builder.md](../builder.md)
Example 3 to wrap the `Union` in a `Struct`. Two options:
1. **Wrap in a struct** (preferred — matches the realistic wire
shape): `Schema::struct_().field("payload", Schema::union_(...))`.
2. **Document the constraint explicitly**: keep the top-level union
in the example but add a note that `AlkTypeEngine::compile`
requires `AlkType:Struct` at the root; a top-level `Union` is for
`jsonschema`-only validation (no `validate_bytes`).
Option 1 is preferred — it shows the shape the engine actually
consumes and matches the SFTP wire format.
- **Resolution**: Not yet applied. Documentation fix in builder.md,
not an implementation change.
- **Resolution**: Applied. [../builder.md](../builder.md) Example 3
now wraps the `Union` in a `Schema::struct_().field("payload", ...)`
and merges `$defs` via `Definitions::merge_into`. This matches the
realistic SFTP wire shape (`[type:u8][payload-struct]`) and the
engine's constraint that `AlkTypeEngine::compile` requires
`AlkType:Struct` at the top level (inherited from
`OffsetMap::compute` / `SequentialReader::new`, ADR-002).
- **Cross-references**: [ADR-009](../decisions/009-builder-api.md),
[builder.md](../builder.md) §"Example 3: SFTP Packet union",
[ADR-002](../decisions/002-two-layout-modes-packed-vs-aligned.md)

View File

@@ -2,43 +2,33 @@
- **Origin**: [../../research/alktype-builder-poc/findings.md](../../research/alktype-builder-poc/findings.md)
§"Open Questions surfaced" (raised during the v0.1.0 POC)
- **Status**: open
- **Status**: resolved
- **Door type**: One-way (the choice affects what the validator sees;
changing it after consumers depend on the shape is a semantic break)
- **Priority**: medium
- **Impacts**: Blocks the SFTP use case for `validate_bytes`. SFTP
packets have binary `handle` and `data` fields (`AlkType:Bytes`)
that are not valid UTF-8. The current materializer uses
`String::from_utf8_lossy` to convert raw bytes to a JSON string
(because `jsonschema`'s `BytesValidator` expects a string — JSON
has no native byte type; see schema-layer.md §TBytes). Lossy
conversion corrupts non-UTF-8 bytes (replacing invalid sequences
with `U+FFFD`), so the validator sees corrupted content. Does NOT
block the chunk header (no `Bytes` fields) or call input schema
(no AlkType kinds).
- **Investigation target**: Decide how `AlkType:Bytes` should be
materialized for validation. Three candidates:
- **Resolution**: Array of u8. The materializer produces
`Value::Array` of `Value::Number` (one entry per byte, 0..=255) for
`AlkType:Bytes` fields. The `BytesValidator` accepts both
`Value::String` (for `validate_json` consumers that hold a JSON
representation) and `Value::Array` (for `validate_bytes`
materialization). `maxLength` is interpreted as max byte count
(array length for the array form, string byte length for the string
form).
1. **Lossy UTF-8 (current)**: `String::from_utf8_lossy`. Simple;
corrupts non-UTF-8. Acceptable only for UTF-8-only `Bytes` fields.
2. **Base64 encode**: encode raw bytes as base64, validate against
a `format: "base64"` constraint. Round-trips cleanly but the
validator sees base64, not raw bytes — `maxLength` semantics
change (base64 length ≠ byte length).
3. **Array of u8**: materialize as `Value::Array` of `Value::Number`
(one entry per byte). The validator sees the actual byte values;
`maxLength` becomes "max array length." Semantically clean but
produces large `Value` trees for big `Bytes` fields.
The array form is the round-trippable form for non-UTF-8 bytes —
`String::from_utf8_lossy` corrupts non-UTF-8 byte sequences
(replacing invalid sequences with `U+FFFD`, which is 3 bytes in
UTF-8, breaking `maxLength` semantics). Base64 encoding changes
`maxLength` semantics (base64 length != byte length). Array of u8
is semantically clean: each entry is the actual byte value, and
`maxLength` is the actual byte count.
The right answer depends on what the `BytesValidator` (in
`src/validation.rs`) should check. Today it checks string length
against `maxLength` — which assumes the string form. If we keep
the string form, base64 is the only round-trippable option. If we
want raw bytes, the validator needs to accept `Value::Array` of
numbers.
- **Resolution**: Not yet decided. Work through the SFTP `handle`/
`data` fields in the next POC round to surface what
`validate_bytes` callers actually need.
Trade-off: array-of-u8 produces larger `Value` trees for big
`Bytes` fields (one `Value::Number` per byte). For the SFTP use
case (handles up to 256 bytes, data up to 32768 bytes), this is
acceptable. A future "validate bytes without materializing" path
(two-way door, out of scope for v0.1.0 per ADR-010) would avoid the
allocation for hot paths.
- **Cross-references**: [ADR-010](../decisions/010-generalized-validation-validate-bytes.md),
`src/materialize.rs` (`AlkTypeKind::Bytes` branch),
`src/validation.rs` (`BytesValidator`),

View File

@@ -0,0 +1,38 @@
# OQ-008: `UnionValidator` variant dispatch — validate variant fields against variant schema
- **Origin**: Raised during the v0.1.0 POC round 2 (SFTP Packet
`validate_bytes` POC). Surfaced when the over-`maxLength` `Bytes`
test failed: the materializer read the bytes correctly, but the
validator did not check the variant's `maxLength` constraint because
the `UnionValidator` was structural-only (just `is_object`).
- **Status**: resolved
- **Door type**: One-way (the validator now recurses into variant
schemas; consumers that depended on the structural-only behavior
would see new validation errors for previously-accepted instances)
- **Priority**: medium
- **Resolution**: `UnionValidator` now builds a sub-validator for each
variant at factory time (when the parent validator tree is
constructed) and dispatches on the `__discriminator` field in the
materialized instance at validation time. The sub-validators are
full `jsonschema::Validator`s built via `build_validator` (so nested
AlkType kinds inside variants are validated correctly).
To make this work, `AlkTypeEngine::compile` now calls
`schema::inline_union_variant_refs` after `normalize_refs` and before
`build_validator`. This inlines `$ref`s in union `mapping` entries by
resolving them against the schema root — necessary because the
`union_factory` receives the union node as `parent`, but `$defs` live
at the schema root (not on the union node). After inlining, each
variant in the `mapping` is a full inline schema, so the factory can
build a sub-validator directly.
The materialized shape for unions is
`{ "__discriminator": <value>, ...variant-fields }` (OQ-005
resolution). The `UnionValidator` reads `__discriminator`, looks up
the corresponding variant sub-validator, and validates the full
instance (including the variant fields) against that sub-validator.
- **Cross-references**: `src/validation.rs` (`UnionValidator`,
`union_factory`), `src/schema.rs` (`inline_union_variant_refs`),
`src/engine.rs` (`AlkTypeEngine::compile`),
[OQ-005](005-union-materialization-shape.md),
[ADR-010](../decisions/010-generalized-validation-validate-bytes.md)

View File

@@ -130,13 +130,18 @@ type constraints; `jsonschema` handles all structural validation.
must not exceed it.
**`AlkType:Bytes`:**
- Value must be a string (JSON represents binary data as a string — JSON
has no native byte type).
- If `maxLength` is specified, the byte length must not exceed it.
- Value must be a string (the JSON form for `validate_json` consumers)
or an array of integers 0..=255 (the materialized form for
`validate_bytes`). JSON has no native byte type; the string form is
the JSON convention, the array form is the round-trippable form for
non-UTF-8 bytes (see [OQ-007](questions/007-bytes-materialization-lossy-utf8.md)).
- If `maxLength` is specified, the byte length must not exceed it. For
the string form, this is the string's byte length; for the array
form, this is the array length (one entry per byte).
- **Binary representation:** In the binary layout, `TBytes` is raw bytes
with no encoding (not base64, not hex). The JSON representation (for
validation) uses a string; the binary representation (for data access)
uses `&[u8]` directly.
validation) uses a string or array; the binary representation (for
data access) uses `&[u8]` directly.
**`AlkType:Enum`:**
- The `AlkType:Enum` custom keyword signals that the type is an enum for
@@ -161,9 +166,23 @@ type constraints; `jsonschema` handles all structural validation.
validate that each field's value matches its `AlkType:*` kind.
**`AlkType:Union`:**
- The discriminator value must be one of the mapping keys.
- The variant struct must match the declared schema for that discriminator
value.
- The instance must be an object with a `__discriminator` field
carrying the mapping key (stringified discriminator value for
byte-offset discriminators, string value for field-name
discriminators). This is the shape the materializer produces for
`validate_bytes`; `validate_json` consumers produce the same shape
when validating a union instance.
- The `UnionValidator` builds a sub-validator for each variant at
factory time (when the parent validator tree is constructed) and
dispatches on `__discriminator` at validation time, validating the
full instance (including the variant fields) against the selected
variant's schema. This closes the OQ-008 gap: variant field
constraints (e.g. `maxLength` on a `Bytes` field inside a variant)
are checked.
- `$ref`s in the union's `mapping` are inlined by
`schema::inline_union_variant_refs` during `AlkTypeEngine::compile`
(before `build_validator`), so the `union_factory` sees full inline
variant schemas. See [OQ-008](questions/008-unionvalidator-variant-dispatch.md).
**`AlkType:Array`:**
- Value must be an array.

View File

@@ -2,28 +2,34 @@
status: complete
last_updated: 2026-08-11
poc_code: /workspace/alktype-builder-poc/
result: PASS (11/11 tests)
result: PASS (18/18 tests)
---
# alktype-builder-poc — Findings
POC validating the v0.1.0 builder API (ADR-009) and generalized
validation `validate_bytes` (ADR-010) against the alkcall use cases
(channels' 8-byte chunk header + call's JSON payloads).
(channels' 8-byte chunk header + call's JSON payloads), and the
round-2 SFTP Packet `validate_bytes` POC (union with byte
discriminator, 5 variants, `$ref`s into `$defs`, binary `Bytes`
fields).
## TL;DR
**Result: PASS (11/11 tests).** Both v0.1.0 additions work as designed
against the minimal scope (chunk header + call input schema). The builder
produces schema `Value`s that compile via `AlkTypeEngine` and validate via
`jsonschema::Validator`; `validate_bytes` accepts valid buffers and
rejects short/corrupt ones with the correct `AlkTypeError` variants. The
two-roles-from-one-library goal is met.
**Result: PASS (18/18 tests).** Both v0.1.0 additions work as designed
against the minimal scope (chunk header + call input schema) and the
round-2 scope (SFTP Packet union with `$ref` variants and non-UTF-8
`Bytes` fields). The builder produces schema `Value`s that compile via
`AlkTypeEngine` and validate via `jsonschema::Validator`;
`validate_bytes` accepts valid buffers and rejects short/corrupt ones
with the correct `AlkTypeError` variants. The two-roles-from-one-library
goal is met.
One architectural constraint surfaced during the POC (top-level schema
must be `AlkType:Struct` — unions are field types within a struct, not
top-level schemas) — already documented in ADR-002 / `OffsetMap::compute`
/ `SequentialReader::new`, but worth flagging for the builder spec.
**Round 2 resolved 5 open questions** (OQ-004, OQ-005, OQ-006, OQ-007,
OQ-008) and fixed 6 production-readiness issues in `src/` (Record stub,
field-name union offset bug, broken `root_of()` for `$ref` resolution,
incomplete `LengthPrefixed` encoding setter, lossy UTF-8 `Bytes`
materialization, POC-referencing comments). See §"Round 2 changes" below.
## POC code
@@ -31,14 +37,14 @@ top-level schemas) — already documented in ADR-002 / `OffsetMap::compute`
```
Cargo.toml # path dep on ../@alkdev/alktype
src/main.rs # 11 tests, 3 groups (builder round-trip, validate_bytes, validate_json)
src/main.rs # 18 tests, 4 groups (builder, validate_bytes, validate_json, SFTP Packet)
```
Run: `cargo run --release` from the POC directory. Exit code 0 = all pass.
## Scope
Minimal scope (agreed before the POC):
### Round 1 (minimal scope, completed in the prior session)
- **Channels' 8-byte chunk header** — `Struct { channel_id: u32 BE, length:
u32 BE }`, packed mode, big-endian. Builder round-trip + `validate_bytes`.
@@ -49,17 +55,17 @@ Minimal scope (agreed before the POC):
named `$defs`, wrapped in a `Struct` (see Findings #1).
- **`validate_json` for call payloads** — the non-binary path.
Out of scope for this POC (deferred to the next round):
### Round 2 (this session)
- `validate_bytes` for `Union`/`Record` (the materializer handles `Struct`
and `Array`; `Union` byte-offset dispatch is implemented but not exercised
end-to-end against a binary buffer in the POC).
- `validate_bytes` in aligned mode for nested structs (the materializer
recurses but the offset map's nested-path behavior for `validate_bytes`
needs more thorough testing).
- Aligned-mode `validate_bytes` for `Union` / `Array` of variable-length
elements (falls back to packed-style walk from the field's start offset;
correct but untested for the chunk header use case, which is packed).
- **SFTP Packet `validate_bytes` end-to-end** — a struct wrapping a union
with a byte discriminator (Uint8 at offset 0) and 5 variants (`Init`,
`Open`, `Read`, `Write`, `Status`) referenced via `$defs`. Tests valid
packets, short buffers, wrong discriminator values, and over-`maxLength`
`Bytes` fields. Exercises the materializer's `$ref` resolution, union
dispatch, and the new array-of-u8 `Bytes` materialization (OQ-007).
- **Non-UTF-8 `Bytes` fields** — `Read.handle` and `Write.data` carry
raw bytes (e.g. `[0x00, 0xFF, 0x01]`) that are not valid UTF-8. Verifies
the array-of-u8 materialization round-trips without corruption.
## Tests
@@ -77,9 +83,105 @@ Out of scope for this POC (deferred to the next round):
| 10 | validate_json | `validate_json_accepts_valid_operation_input` | PASS |
| 11 | validate_json | `validate_json_rejects_missing_required` | PASS |
| 12 | validate_json | `validate_json_rejects_out_of_range` | PASS |
| 13 | sftp_packet | `sftp_packet_init_valid` | PASS |
| 14 | sftp_packet | `sftp_packet_read_valid_with_bytes_handle` | PASS |
| 15 | sftp_packet | `sftp_packet_write_valid_with_bytes_data` | PASS |
| 16 | sftp_packet | `sftp_packet_rejects_short_buffer` | PASS |
| 17 | sftp_packet | `sftp_packet_rejects_unknown_discriminator` | PASS |
| 18 | sftp_packet | `sftp_packet_rejects_over_maxlength_bytes` | PASS |
| 19 | sftp_packet | `sftp_packet_status_valid` | PASS |
(11 logical tests; #12 was renumbered into the validate_json group — see
the POC source for the exact list. All pass.)
(18 logical tests; #19 was renumbered. All pass.)
## Round 2 changes
### Production-readiness fixes in `src/`
The prior session's POC left several "POC-level" hedges in the
production crate (`src/`). These were all fixed in round 2 — no stubs
or "fix it later" hedges remain in a crate intended for crates.io
publication.
1. **`materialize.rs` Record stub -> full implementation.** The
`AlkType:Record` branch returned an `Access` error with "not yet
supported in validate_bytes (POC)". Replaced with real
count-prefixed key/value pair materialization per schema-layer.md
§TRecord: `[count: u32][key_len: u32][key_bytes][value]...`. The
materialized form is a JSON object mapping each key to its
materialized value.
2. **`materialize.rs` field-name union offset bug + shape asymmetry
(OQ-005).** The field-name path returned `Ok((value, offset))`
(start offset, not end) — a real correctness bug where subsequent
fields after a field-name union would read from the wrong position.
Fixed: the path now returns the new offset after the variant
struct. Both discriminator kinds now return the same shape:
`{ "__discriminator": <value>, ...variant-fields }`.
3. **`materialize.rs` `root_of()` was broken.** The helper returned
the current node, not the schema root, so `resolve_ref_or_inline`
couldn't resolve `$ref`s for nested composites. This broke any
union/array with `$ref` variants (the SFTP shape). Fixed: the root
schema is now threaded through every recursive call in the
materializer (`materialize_struct_packed`, `materialize_field_packed`,
etc.).
4. **`builder.rs` `LengthPrefixed` encoding setter was a no-op.** The
`LengthPrefixed` case when the keyword was already in object form
didn't set the `encoding` key (comment said "For the POC we don't
reach this branch"). Fixed: the setter now updates the `encoding`
entry inside the keyword's object form in place, for both
`LengthPrefixed` and `OffsetIndirect`.
5. **`materialize.rs` Bytes materialization lossy UTF-8 (OQ-007).**
`String::from_utf8_lossy` corrupted non-UTF-8 bytes and broke
`maxLength` semantics (4 bytes of `0xFF` → 12 bytes of `U+FFFD` in
UTF-8, so `maxLength: 4` would fail). Replaced with array-of-u8:
the materializer produces `Value::Array` of `Value::Number` (one
entry per byte, 0..=255). The `BytesValidator` now accepts both
`Value::String` (for `validate_json`) and `Value::Array` (for
`validate_bytes`). `maxLength` = max byte count.
6. **POC-referencing comments cleaned up.** `builder.rs:663`,
`engine.rs:868`, `engine.rs:971` had comments referencing "the
POC's primary use case" / "back to manual byte layout for the POC
round-trip test". Rewritten to describe actual use cases. The
round-trip test's `or_else` fallback (which papered over
`write_field` being aligned-only) was removed in favor of direct
byte writes (the test is about `validate_bytes`, not `write_field`).
### New implementation: `UnionValidator` variant dispatch (OQ-008)
The prior `UnionValidator` was structural-only (`is_object`) — it did
not dispatch to variant schemas. This meant `validate_bytes` on a
union would check bytes are readable (materializer phase) but NOT
validate variant field constraints (e.g. `maxLength` on a `Bytes`
field inside a variant). Surfaced when the SFTP POC's over-`maxLength`
test failed.
**Fix**: `UnionValidator` now builds a sub-validator for each variant
at factory time and dispatches on `__discriminator` at validation time.
The sub-validators are full `jsonschema::Validator`s built via
`build_validator` (so nested AlkType kinds inside variants are
validated).
**Supporting change**: `AlkTypeEngine::compile` now calls
`schema::inline_union_variant_refs` after `normalize_refs` and before
`build_validator`. This inlines `$ref`s in union `mapping` entries by
resolving them against the schema root — necessary because the
`union_factory` receives the union node as `parent`, but `$defs` live
at the schema root. After inlining, each variant in the `mapping` is
a full inline schema.
### Documentation fixes
- **OQ-004** (resolved): `Discriminator::Field` name is `String`. Updated
builder.md spec + OQ-004 file + open-questions.md.
- **OQ-006** (resolved): builder.md Example 3 now wraps the `Union` in a
`Schema::struct_().field("payload", ...)` and merges `$defs` via
`Definitions::merge_into`. Updated OQ-006 file + open-questions.md.
- **validation.md**: Updated `AlkType:Bytes` and `AlkType:Union` validator
descriptions to reflect the array-of-u8 form and the variant dispatch.
## Findings
@@ -94,23 +196,12 @@ inherited by the engine.
**Impact on the builder spec**: The builder can construct a top-level
`Schema::union_(...)`, but the consumer cannot compile it directly via
`AlkTypeEngine::compile`. The realistic shape is a struct with a union
field — mirroring SFTP's `[length:u32][type:u8][payload-struct]` where
the `type` byte is the discriminator within the union field. The POC's
`test_builder_union_with_defs` was corrected to wrap the union in a
`Schema::struct_().field("payload", Schema::union_(...))`.
field — mirroring SFTP's `[type:u8][payload-struct]` where the `type`
byte is the discriminator within the union field.
**Action**: The builder spec ([builder.md](../../../architecture/builder.md))
should note this constraint in Example 3 (SFTP Packet). The current
example shows a top-level `Schema::union_(...)` which won't compile as-is.
Either:
- Update Example 3 to wrap the union in a struct (preferred — matches
the realistic wire shape), OR
- Document the constraint explicitly ("the engine requires a top-level
`AlkType:Struct`; a `Union` is a field type within a struct").
This is a documentation fix, not an implementation change. Tracked as
[OQ-006](../../../architecture/questions/006-builder-spec-example-3-wrap-union.md).
**Action**: Applied. The builder spec ([builder.md](../../../architecture/builder.md))
Example 3 now wraps the union in a struct and merges `$defs`
(OQ-006 resolved).
### 2. Builder field order is preserved as required
@@ -123,149 +214,86 @@ order = byte order — ADR-002).
### 3. `validate_bytes` correctly distinguishes read errors from validation errors
The two-phase pipeline (materialize `Value` from bytes validate `Value`
The two-phase pipeline (materialize `Value` from bytes -> validate `Value`
against `jsonschema`) produces distinct error variants:
- `AlkTypeError::Access` for read-phase failures (buffer too short,
invalid UTF-8). Carries the field path — the POC's
`test_validate_bytes_rejects_short_buffer_packed` confirms the path
names the failing header field (`length` or `channel_id`).
invalid UTF-8, unknown discriminator value). Carries the field path.
- `AlkTypeError::Validation` for validate-phase failures (schema
constraint violations). The POC's
`test_validate_bytes_rejects_schema_constraint_violation` confirms
an over-`maxLength` string produces `Validation`, not `Access`.
This matches ADR-010's spec.
constraint violations, including variant field constraints after
OQ-008).
### 4. Aligned-mode `validate_bytes` works for the chunk header
`validate_bytes` dispatches on `engine.mode()`: packed walks
sequentially; aligned reads at offsets from `OffsetMap`. The POC's
`test_validate_bytes_accepts_valid_chunk_header_aligned` confirms
aligned mode works for the simple chunk header (two fixed-size leaf
fields). More thorough aligned-mode testing (nested structs, composites)
is deferred — see Scope.
aligned chunk header test confirms aligned mode works for simple
fixed-size leaf fields.
### 5. `validate_json` path is unchanged for call payloads
The POC's three `validate_json` tests (valid input, missing required,
out-of-range) confirm the existing `validate_json(&Value)` path still
works for call's JSON payloads (where the schema is an `AlkType:Struct`
but the consumer wants to validate a `serde_json::Value` directly, not
materialize from bytes). This is the path alkcall uses for
`OperationSpec.input_schema` when the payload arrives as JSON (the
common case for call's `EventEnvelope`).
works for call's JSON payloads.
For pure JSON Schema (no AlkType kinds, e.g. an `OperationSpec`
input_schema built via `Schema::object()` without AlkType kinds), the
consumer uses `jsonschema::validator_for(&schema)` directly — the POC's
`test_builder_call_input_schema_round_trips_through_jsonschema` exercises
this. `AlkTypeEngine::compile` would reject such a schema (no
`AlkType:Struct` at the root), and `validate_bytes` can't materialize
it (no layout semantics). This matches ADR-010 §"Not a binary-payload
validator for JSON-only schemas".
### 6. SFTP Packet end-to-end (round 2)
## Implementation notes
The SFTP Packet POC (7 tests) confirms the full pipeline works for a
realistic binary protocol schema:
### Files added to alktype
- **`$ref` resolution**: The materializer resolves `#/$defs/Init` etc.
against the schema root (root-schema threading fix). The
`UnionValidator`'s sub-validators are built from inlined variants
(`inline_union_variant_refs` at compile time).
- **Byte-offset union dispatch**: The materializer reads the Uint8
discriminator at offset 0, looks up the variant in the `mapping`,
and materializes the variant struct fields. The `UnionValidator`
dispatches on `__discriminator` and validates the variant fields
against the variant schema.
- **Non-UTF-8 `Bytes` fields**: `Read.handle` = `[0x00, 0xFF, 0x01]`
and `Write.data` = `[0x00, 0x01, 0xFE, 0xFF]` round-trip correctly
via the array-of-u8 materialization. The old `from_utf8_lossy` path
would have corrupted these.
- **Over-`maxLength` `Bytes`**: A 257-byte handle (exceeds
`maxLength: 256`) is rejected by the `BytesValidator` after
materialization (the length prefix is valid, so the read phase
succeeds, but the validator catches the constraint violation).
This test would have failed before OQ-008 (the `UnionValidator`
didn't dispatch to the variant schema, so the `maxLength` was never
checked).
- `src/builder.rs` — the `Schema`, `Definitions`, `Discriminator` types
and their methods. ~670 lines including tests.
- `src/materialize.rs` — `materialize_packed` and `materialize_aligned`
functions that walk a schema + buffer to produce a `serde_json::Value`
tree. ~460 lines. Recurses into `Struct`, `Array`, `Union` (byte-offset
discriminator). `Record` is stubbed (returns `Access` error) — deferred
for the POC scope.
- `src/engine.rs` — `AlkTypeEngine::validate_bytes(&[u8])` added after
`is_valid_json`. Dispatches on the layout mode, materializes, then
validates. 7 new tests in the engine's test module.
- `src/lib.rs` — `pub mod builder`, `pub mod materialize`; re-exports
`Schema`, `Definitions`, `Discriminator`.
## Open Questions resolved in round 2
### Test counts
- **OQ-004** (resolved): `Discriminator::Field` name is `String`.
- **OQ-005** (resolved): Both union discriminator kinds return
`{ "__discriminator": <value>, ...variant-fields }`. Field-name
offset bug fixed.
- **OQ-006** (resolved): builder.md Example 3 wraps the union in a
struct + merges `$defs`.
- **OQ-007** (resolved): `Bytes` materialization is array-of-u8;
`BytesValidator` accepts both string and array forms.
- **OQ-008** (resolved): `UnionValidator` dispatches to variant
schemas via sub-validators; `inline_union_variant_refs` inlines
`$ref`s at compile time.
- alktype crate: 346 → 369 tests (23 new: 16 builder, 7 `validate_bytes`).
All pass; clippy clean.
- POC: 11 tests, all pass.
No new open questions surfaced during round 2.
### Known limitations of the POC implementation
## Test counts
1. **`Record` materialization** is stubbed in `materialize.rs` (returns
`AlkTypeError::Access`). The chunk header doesn't use `Record`, so
the POC scope doesn't require it. Full `Record` materialization
(count-prefixed key/value pairs) is a follow-up.
2. **`Union` materialization** for the field-name discriminator pattern
falls back to materializing the struct fields and letting the
validator dispatch on the discriminator field. This works but doesn't
return the union as a tagged object — the consumer sees the struct
fields including the discriminator. The byte-offset discriminator
pattern returns a `__discriminator`-tagged object. This asymmetry
needs resolution before shipping (likely both should return the same
shape — tracked as
[OQ-005](../../../architecture/questions/005-union-materialization-shape.md)).
3. **`Bytes` materialization** uses `String::from_utf8_lossy` to convert
raw bytes to a JSON string (the validator expects a string for
`AlkType:Bytes` — see schema-layer.md §TBytes). This is lossy for
non-UTF-8 bytes; strict byte-preserving validation would need a
different validator form. For the chunk header (no `Bytes` fields),
this doesn't matter, but it's a follow-up for the SFTP use case
(tracked as
[OQ-007](../../../architecture/questions/007-bytes-materialization-lossy-utf8.md)).
4. **`encoding` setter** in the builder only handles the
`OffsetIndirect` case (rewriting the boolean-true form to the object
form with the `encoding` annotation). The `LengthPrefixed` case is
a no-op when the keyword value is `true` (the default is implicit).
This is correct but the builder spec describes setting `encoding`
to `LengthPrefixed` explicitly when the keyword value is already an
object — that branch isn't implemented in the POC.
## Open Questions surfaced
Three new open questions raised during the POC, now tracked in the
central OQ tracker ([open-questions.md](../../../architecture/open-questions.md)):
- **OQ-004** (open, low): `Discriminator::Field` name type — `&str` or
`String`? Raised in [builder.md](../../../architecture/builder.md)
§"Open Questions" during ADR-009 spec drafting. Doesn't block the
chunk header POC; must be resolved before the SFTP Packet POC's
field-name discriminator path. Full file:
[OQ-004](../../../architecture/questions/004-discriminator-field-name-type.md).
- **OQ-005** (open, medium): `Union` materialization shape —
consistency between byte-offset and field-name discriminators.
The POC's `materialize_union_packed` returns inconsistent shapes
for the two kinds. Blocks the SFTP Packet `validate_bytes` POC (the
natural next round). Full file:
[OQ-005](../../../architecture/questions/005-union-materialization-shape.md).
- **OQ-006** (open, low): Builder spec Example 3 — wrap the `Union`
in a `Struct`. The spec example shows a top-level `Schema::union_(...)`
which won't compile (`AlkTypeEngine::compile` requires
`AlkType:Struct` at the top level — Finding #1 above). Documentation
fix. Full file:
[OQ-006](../../../architecture/questions/006-builder-spec-example-3-wrap-union.md).
- **OQ-007** (open, medium, one-way door): `Bytes` materialization —
lossy UTF-8 conversion. The current materializer uses
`String::from_utf8_lossy`, which corrupts non-UTF-8 bytes. Blocks
the SFTP use case for `validate_bytes` (binary `handle`/`data`
fields). Full file:
[OQ-007](../../../architecture/questions/007-bytes-materialization-lossy-utf8.md).
- alktype crate: 369 -> 391 tests (22 new: 14 materialize, 5
inline_union_variant_refs, 3 validation/builder). All pass; clippy
clean.
- POC: 11 -> 18 tests (7 new SFTP Packet tests). All pass.
## Recommendation
**Proceed to the next POC round.** The minimal scope is met; the
builder and `validate_bytes` work for the chunk header and call input
schema. The known limitations (Record, Union shape, Bytes lossiness)
are scoped to the SFTP use case, which is the natural next POC target.
Before that POC:
**v0.1.0 is ready to ship.** All open questions from the prior POC
round are resolved. The SFTP Packet use case (the natural next target)
is validated end-to-end. No architectural changes are needed. The
production-readiness issues (stubs, hedges, broken helpers) are fixed.
1. Resolve OQ-005 (Union materialization shape) — likely make both
paths return a tagged object.
2. Fix the builder spec's Example 3 (OQ-006) to wrap the union in a
struct.
3. Decide on OQ-007 (Bytes lossiness) — affects SFTP `handle`/`data`.
No architectural changes needed for v0.1.0 as specced. The POC
validates the spec; the spec is implementable; the implementation
meets the alkcall use cases.
The remaining deferred OQs (OQ-001, OQ-002) are scope-managed and do
not block v0.1.0.
## References