Files
alktype/docs/reviews/002-code-review.md
glm-5.2 fb3a27f974 Pre-publish docs sweep: README, AGENTS.md, licenses, inline doc fixes
- Add README.md reflecting the v0.1.0 state: 19 AlkType kinds, two
  layout modes, builder + AlkTypeEngine usage example (verified to
  compile and run), validation entry points, crate independence,
  untrusted-schemas guarantee, docs pointers. Mirrors the alkvault
  README structure.
- Add AGENTS.md with alktype-specific git workflow, project
  conventions (no comments, AlkTypeError, untrusted schemas, overflow
  safety, no async, no feature flags, wasm-clean, preserve_order
  load-bearing, no unsafe), verification commands, and ADR/OQ index.
  Blocks auto-commit on semver-relevant public API changes per the
  crates.io 0.1.0 contract.
- Add LICENSE-MIT and LICENSE-APACHE (dual MIT/Apache-2.0, matching
  alkvault and the Cargo.toml license field).
- Cargo.toml: add readme, keywords, categories, rust-version = "1.85".
- Fix broken intra-doc link in builder.rs: DiscriminatorKind ->
  crate::schema::DiscriminatorKind (cargo doc now warning-free).
- N1 (review #002): document is_rfc3339_timestamp as non-strict in the
  function doc comment. Lists the specific gaps (day-of-month per
  month, seconds range, leap seconds) and points consumers needing
  strict validation to chrono/time.
- N2 (review #002): document the FieldValue::Bytes-for-Record API
  asymmetry in the FieldValue enum doc and on read_record_value.
- .opencode/agents/implementation-specialist.md: point to AGENTS.md
  for full convention details (matches the alkvault pattern).
- review #002: mark N1/N2 resolved; all 7 findings now closed.

Verification:
- cargo test --release: 396 tests pass (310 crate + 86 integration)
- cargo clippy --all-targets -- -D warnings: clean
- cargo doc --no-deps: clean (no broken intra-doc link warnings)
- cargo build --target wasm32-unknown-unknown --release: clean
- cargo publish --dry-run --allow-dirty: clean
2026-08-11 09:33:33 +00:00

23 KiB

status, last_updated, reviewed_artifacts, tool, reviewer
status last_updated reviewed_artifacts tool reviewer
resolved (M1, M2, L1, L2, L3, N1, N2) 2026-08-11
src/lib.rs
src/builder.rs
src/data_access.rs
src/engine.rs
src/error.rs
src/layout_builder.rs
src/materialize.rs
src/offset_map.rs
src/schema.rs
src/sequential_reader.rs
src/tunion.rs
src/validation.rs
src/macros.rs
tests/{engine_integration,error_paths,poc_roundtrip,tunion_dispatch}.rs
Cargo.toml
manual source read + cargo test/clippy/wasm build pre-publish code review (pre-cargo-publish sweep)

Code Review #002 — Pre-Publish Sweep

Purpose

First logic/correctness review of the alktype crate ahead of the v0.1.0 crates.io publish. This pass complements the coverage review (#001) by reading every source file for correctness, code smell, panic safety, and API ergonomics — the things a downstream consumer (notably the upcoming alkcall crate, the alknet-call/alknet-channels unification) will trip over.

This review deliberately defers documentation polish (README, inline doc cleanup for docs.rs) and coverage gaps to subsequent sweeps, per the publisher's stated workflow. The scope here is: no panics in production paths, no silent data corruption, no API that returns nonsense, and nothing that would embarrass the crate on docs.rs on day one.

Methodology

  • Full read of all 13 src/*.rs files (production + test modules).
  • Grep for unwrap/expect/panic!/unreachable!/unsafe/TODO/ FIXME to classify every hit as production or test-only.
  • Grep for as u32/as usize/as i32/as i64/as u64 to find truncation-prone casts.
  • cargo build --release, cargo test --release, cargo clippy --all-targets -- -D warnings, cargo build --target wasm32-unknown-unknown --release.
  • Cross-reference every error path against its caller to confirm errors propagate (not swallowed) and carry useful attribution.
  • Read docs/reviews/001-coverage-analysis.md for prior context and resolved/unresolved items.

Summary Statistics

Severity Count
Critical 0
Medium 2 (M1, M2)
Low 3 (L1, L2, L3)
Nit 2 (N1, N2)

No critical findings. The crate is in good shape for a 0.1.0 publish once M1 and M2 are resolved. The Medium findings are both small, localized, and have clear fixes; the Low/Nit findings are deferrable.

Verification Baseline

All verification run on the reviewed tree (commit c0217d9):

  • cargo test --release: 438 tests pass (391 crate unit tests + 47 integration tests across 4 files). Zero failures.
  • cargo clippy --all-targets -- -D warnings: clean.
  • cargo build --release: clean.
  • cargo build --target wasm32-unknown-unknown --release: clean. The wasm32 target is a first-class supported target (the alkcall consumer will rely on this — confirmed working).
  • No unsafe anywhere in the crate.
  • No TODO/FIXME/HACK/XXX markers in source.
  • All unwrap/expect/panic!/unreachable! are confined to #[cfg(test)] modules, verified by line-context cross-reference. Production code uses ?, ok_or_else, checked_add, and Result-returning helpers throughout.

Findings

M1. Definitions::merge_into is convoluted and silently drops data

File: src/builder.rs:493-503

Problem: Definitions::merge_into is the public convenience API for merging $defs into a top-level schema. The implementation goes through self.build() (which returns {"$defs": {...}}), then extracts the inner $defs object via a double-cloned/double- unwrap_or_default chain:

pub fn merge_into(self, top: &mut Value) {
    if let Some(obj) = top.as_object_mut() {
        if let Some(defs) = self.build().as_object() {
            if let Some(existing) = obj.get_mut("$defs").and_then(Value::as_object_mut) {
                existing.extend(defs.get("$defs").cloned().unwrap_or_default()
                    .as_object().cloned().unwrap_or_default());
            } else {
                obj.insert("$defs".to_string(), Value::Object(
                    defs.get("$defs").cloned().unwrap_or_default()
                        .as_object().cloned().unwrap_or_default()));
            }
        }
    }
}

Two issues:

  1. Silent data loss: if self.build() ever produces a shape other than {"$defs": Object(_)} (a future bug, or a refactor that adds another key), the .unwrap_or_default() chain silently substitutes an empty map — the definitions vanish without an error. This is the worst failure mode for a builder API: the consumer thinks they merged definitions, the schema compiles, and $ref resolution silently fails downstream.

  2. Readability: the double-cloned().unwrap_or_default().as_object() .cloned().unwrap_or_default() chain is hard to read and hard to audit. A reviewer cannot tell at a glance whether it's correct.

Also: merge_into has no test coverage. The builder_definitions_define_returns_ref test covers Definitions::build() but not merge_into. The OQ-006 resolution work referenced merge_into in builder.md Example 3, so the API is intended to be used — but it's never exercised.

Fix: Simplify by accessing the defs field directly (the method already takes self by value) instead of round-tripping through build(). Then add tests covering both the "merge into schema with existing $defs" and "merge into schema without $defs" branches.

pub fn merge_into(self, top: &mut Value) {
    if let Some(obj) = top.as_object_mut() {
        match obj.get_mut("$defs").and_then(Value::as_object_mut) {
            Some(existing) => existing.extend(self.defs),
            None => {
                obj.insert("$defs".to_string(), Value::Object(self.defs));
            }
        }
    }
}

This requires moving the method outside the impl Definitions block that consumes self.defs via build(), or restructuring so merge_into takes ownership of self.defs directly. Either way, the unwrap_or_default chain disappears.

Lift: removes a silent-data-loss bug class, makes the public API auditable, adds test coverage to an untested public method. Small effort (~15 lines + ~15 lines of tests).


M2. write_bytes silently truncates length prefix on >4 GiB data

File: src/data_access.rs:264-278

Problem: write_bytes casts data_len: usize to u32 for the length prefix without checking bounds:

let data_len = value.len();
let total = U32_SIZE.checked_add(data_len).ok_or_else(/* overflow */)?;
let end = offset.checked_add(total).ok_or_else(/* overflow */)?;
check_bounds(buffer.len(), offset, end, field_path)?;
write_array(buffer, offset, u32_to(data_len as u32, endian), field_path)?;

If value.len() > u32::MAX (on a 64-bit platform with a >4 GiB blob), data_len as u32 truncates. The check_bounds call above passes (the buffer genuinely is that large, or total overflowed usize first on a 32-bit target), but the written length prefix doesn't match the actual data length. A subsequent read_bytes returns a short slice — silent data corruption.

The checked_add guards on total and end catch usize overflow but not the u32 truncation, which can happen before usize overflow on 64-bit platforms (where usize is 64-bit and u32::MAX < usize::MAX).

The same as u32 cast pattern appears in:

  • src/tunion.rs:457,493 — test-only, fine.
  • src/sequential_reader.rs:860 — test-only helper, fine.
  • src/engine.rs:857 — test-only, fine.

Only data_access::write_bytes is the public API path.

Fix: validate the cast before writing:

let data_len_u32 = u32::try_from(data_len).map_err(|_| {
    access_err(
        field_path,
        format!("data length {data_len} exceeds u32::MAX (length prefix width)"),
    )
})?;
write_array(buffer, offset, u32_to(data_len_u32, endian), field_path)?;

Lift: closes a silent-corruption path on large inputs. ~3 lines. The 64-bit >4 GiB case is rare but not impossible (the crate handles binary blobs via AlkType:Bytes; a safetensors-style data format could plausibly have large blobs). For a 0.1.0 publish, closing this path is worth the 3 lines.


L1. materialize.rs union path uses unchecked offset arithmetic

File: src/materialize.rs:316,319,336

Problem: The byte-offset discriminator path in materialize_union_packed uses plain + for offset + disc_offset:

let disc_value = match disc_type {
    AlkTypeKind::Uint8 => {
        data_access::read_u8(buffer, offset + disc_offset, field_path)? as u32
    }
    AlkTypeKind::Uint16 => {
        data_access::read_u16(buffer, offset + disc_offset, field_path, endian)? as u32
    }
    ...
};
let variant_offset = offset + disc_offset + disc_type.type_size().unwrap_or(1);

offset + disc_offset can overflow usize. The sibling implementation in sequential_reader.rs::read_union_value (lines 416-424) correctly uses checked_add. The materializer should match.

Low severity because:

  • offset comes from the materializer's own cursor (always small in practice).
  • disc_offset comes from the schema (discriminator.offset), which is attacker-controllable in a "load untrusted schema" scenario — but the crate doesn't currently document whether loading untrusted schemas is a supported use case.

Consistency with sequential_reader.rs is the main argument for fixing this now.

Fix: replace offset + disc_offset with offset.checked_add(disc_offset).ok_or_else(|| ...)? in both spots. ~6 lines, mirrors the existing sequential_reader.rs pattern.


L2. Three unreachable!() in production code

Files: src/offset_map.rs:277, src/layout_builder.rs:285, src/materialize.rs:324

Problem: Three match arms use unreachable!() to assert exhaustiveness over AlkTypeKind:

  • offset_map.rs:277: _ => unreachable!("all AlkTypeKind variants are covered above")
  • layout_builder.rs:285: same
  • materialize.rs:324: _ => unreachable!("disc_type restricted by parse_discriminator")

These are all genuinely unreachable given the match arms above them — the AlkTypeKind enum is fully covered in each case, and parse_discriminator restricts disc_type to Uint8/Uint16/Uint32 before this point. This is the idiomatic Rust pattern for exhaustiveness and the compiler will warn if a new variant is added without updating the match.

Not a bug. But for a library that may eventually load schemas from untrusted input, an unreachable! is a panic in production. The defense-in-depth alternative is to return an AlkTypeError::Schema instead, so a future enum extension (or a logic bug in parse_discriminator) produces an error rather than a panic.

Fix (optional): replace each unreachable!(msg) with Err(AlkTypeError::Schema(format!("internal: {msg}"))). The compiler still warns on non-exhaustive matches (the _ arm catches nothing once all variants are listed), so this doesn't lose the exhaustiveness check. ~3 lines per site. Defer if the "untrusted schema" use case isn't on the v0.1.0 roadmap.


L3. AlkTypeError::source() returns None for Validation variant

File: src/error.rs:45

Problem: AlkTypeError implements the blanket impl std::error::Error for AlkTypeError {}, so source() always returns None. The Validation variant wraps a jsonschema::ValidationError<'static>, which itself implements std::error::Error and may carry a cause chain.

Downstream consumers (the alkcall logging/diagnostics layer) may want to walk the cause chain for structured error reporting. Currently they can only Display the ValidationError (flattened into the AlkTypeError::Display string), not traverse it.

Verified: jsonschema::ValidationError implements std::error::Error (in jsonschema-0.46/src/error.rs), so the source() override is sound.

Fix:

impl std::error::Error for AlkTypeError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            AlkTypeError::Validation(e) => Some(e),
            _ => None,
        }
    }
}

Note the lifetime: AlkTypeError::Validation holds a ValidationError<'static>, so the + 'static bound in source() is satisfied. The existing source_returns_none_for_all_variants test (in error.rs tests) asserts source().is_none() for Schema/ Offset/Access — those still pass. The Validation arm needs a new assertion (source().is_some()).

Lift: small ergonomics win for downstream consumers; ~6 lines + ~5 lines of test. Defer if no consumer needs cause-chain walking yet, but cheap to do now.


N1. is_rfc3339_timestamp is a non-strict hand-rolled validator

File: src/validation.rs:351-385

Problem: The is_rfc3339_timestamp function is a hand-rolled datetime validator. It checks year > 0, month 1..=12, day 1..=31, hour 0..=23, minute 0..=59 — but:

  • Day-of-month per month is not validated (Feb 31, Apr 31 pass).
  • Seconds are not checked against 0..=59 (the code checks minutes but not seconds — line 384 only validates time_parts[1], not time_parts[2] if present).
  • The rfind('-') timezone-offset heuristic at line 363 uses pos >= 8 to distinguish a date-separator - from a timezone offset -. This works for 2026-07-20T10:30:00-05:00 but is fragile for unusual-but-valid inputs.

The function is documented as "Simple RFC 3339 / ISO 8601 datetime check", so the non-strictness is acknowledged. For 0.1.0 this is acceptable — AlkType:Timestamp is a length-prefixed string at the binary level, and strict RFC 3339 validation is the consumer's responsibility if they need it.

The canonical fix would be to use the chrono or time crate's parsing, but that adds a dependency. Not worth it for 0.1.0.

Fix (optional): add a doc comment noting the non-strictness explicitly, so consumers on docs.rs know not to rely on it for strict validation. ~2 lines of doc.


N2. SequentialReader returns FieldValue::Bytes for AlkType:Record

File: src/sequential_reader.rs:716

Problem: read_record_value returns FieldValue::Bytes(&buffer[offset..position]) — a raw byte slice — for a Record field. The other composite kinds return typed descriptors (Struct { start, end }, Union { discriminator, variant_start }, Array { count, element_start, element_stride }).

The doc comment on read_record_value says "the consumer recurses into the record's value schema", so the behavior is documented. But returning FieldValue::Bytes rather than a FieldValue::Record { start, end, count } is a minor API ergonomics smell: the consumer has to know that a Record field comes back as Bytes, while every other composite comes back as a typed variant.

Not a bug. Not worth fixing for 0.1.0 (would require adding a FieldValue::Record variant and updating consumers). Flagged for awareness — if the alkcall consumer finds the Record API awkward, revisit in a follow-up.

Fix: none for 0.1.0. Document as a known API asymmetry.


What's Good

The crate is in notably good shape for a 0.1.0. Highlights:

  • Overflow safety is thorough: checked_add everywhere in hot paths (data_access, sequential_reader, layout_builder, materialize). This is rare and good — most crates use + and panic on overflow. M2 is the one place this discipline slipped (the as u32 cast).
  • Zero-copy reads: read_string/read_bytes return borrowed slices — no allocation in the read path. Critical for the protocol- parsing use case.
  • Error attribution: every Access/Offset error carries a field_path string. Excellent for debugging wire-format issues.
  • Bounds checks before slicing: check_bounds then get(..) with ok_or_else — defensive, no panics on bad offsets.
  • build_validator registers all 19 keywords — no silent passthrough where an AlkType:* kind is accepted but not validated.
  • Tests are excellent: 438 tests, good coverage of error paths and edge cases (short buffers, unknown discriminators, invalid UTF-8, both endiannesses, overflow guards where reachable). The test discipline is high.
  • wasm32 works — confirmed clean build. The alkcall consumer's napi/wasm adapter path is open.
  • No unsafe, no TODO/FIXME — clean codebase hygiene.
  • Error type is well-designed: four variants covering the three engine phases + validation, with Display carrying the field path. L3 is a small ergonomics gap, not a design flaw.

  1. M2 (u32 truncation in write_bytes) — 3 lines, closes a silent-corruption path. Do this first; it's the smallest and highest-value fix.
  2. M1 (Definitions::merge_into) — ~15 lines + ~15 lines of tests, removes a silent-data-loss path and adds coverage to an untested public API. Do this before publish since merge_into is referenced in builder.md Example 3.
  3. L1 (materializer unchecked arithmetic) — ~6 lines, mirrors the existing sequential_reader.rs pattern. Cheap to do alongside M2.
  4. L3 (AlkTypeError::source) — ~6 lines + ~5 lines of tests. Small ergonomics win; cheap to do now.
  5. L2 (unreachable! → Err) — ~9 lines across three sites. Optional; defer if the "untrusted schema" use case isn't on the v0.1.0 roadmap.
  6. N1, N2 — documentation only; defer to the docs sweep.

After M1, M2, L1, and L3, the crate is ready for the pre-publish sanity check (README, inline docs, docs.rs render) and publish.


Notes

  • All line numbers refer to the tree at commit c0217d9 (the last commit on main at review time).
  • The wasm32-unknown-unknown target was verified as a clean build. The crate's only dependencies (jsonschema with default-features = false, serde_json with preserve_order) are wasm-compatible — no std::time, no filesystem, no threads.
  • This review does not cover documentation quality (README, inline docs, docs.rs rendering). Per the publisher's workflow, that's a separate sweep after the code is settled.
  • Coverage gaps from review #001 (S4 sequential reader error paths, S8 overflow guards) are not re-litigated here. They remain coverage gaps, not correctness issues.

Resolution (2026-08-11)

Four of the seven findings were resolved in the same session as the review. 5 new tests added (391 → 396 crate tests; 438 → 443 total with integration tests). cargo test, cargo clippy --all-targets -- -D warnings, and cargo build --target wasm32-unknown-unknown --release all green.

M2 (u32 truncation in write_bytes) — resolved

src/data_access.rs: added a u32::try_from(data_len) guard before the u32_to call. A >4GiB blob now returns AlkTypeError::Access { reason: "data length N exceeds u32::MAX (length prefix width)" } instead of silently writing a truncated length prefix. ~3 lines.

M1 (Definitions::merge_into silent data loss) — resolved

src/builder.rs: rewrote merge_into to access self.defs directly instead of round-tripping through self.build(). The double- cloned().unwrap_or_default().as_object().cloned().unwrap_or_default() chain is gone — the definitions are moved directly into the target's $defs object. 4 new tests cover: insert-when-absent, merge-into- existing, overwrite-duplicate-keys, and no-op-on-non-object-top. ~15 lines + ~50 lines of tests.

L1 (materialize.rs unchecked offset arithmetic) — resolved

src/materialize.rs: the byte-offset discriminator arm of materialize_union_packed now uses checked_add for both offset + disc_offset and disc_abs_offset + disc_size, returning AlkTypeError::Access on overflow. Mirrors the existing pattern in sequential_reader.rs::read_union_value. The variant_offset computation is now overflow-safe. ~15 lines.

L3 (AlkTypeError::source() for Validation) — resolved

src/error.rs: replaced the blanket impl std::error::Error for AlkTypeError {} with an explicit impl that returns Some(inner) for the Validation variant and None for the others. The existing source_returns_none_for_all_variants test was split into two: source_returns_none_for_schema_offset_access (unchanged behavior) and source_returns_some_for_validation_variant (new). ~6 lines + ~10 lines of tests.

Deferred → Resolved (docs sweep)

  • N1 (non-strict is_rfc3339_timestamp): documented as non-strict in the function's doc comment. Lists the specific gaps (day-of-month per month, seconds range, leap seconds) and points consumers needing strict validation to chrono or time. ~10 lines of doc in src/validation.rs. A strict implementation would add a dependency, not worth it for 0.1.0.
  • N2 (FieldValue::Bytes for Record): documented as a known asymmetry in the FieldValue enum doc and on read_record_value. Notes that every other composite kind returns a typed descriptor while Record returns Bytes, and flags the possibility of a future FieldValue::Record variant. ~12 lines of doc across src/sequential_reader.rs.

L2 (unreachable! → Err) — resolved (follow-up)

The three unreachable! sites were originally deferred. After discussion with the publisher, the immediate downstream consumer (alkcall) accepts schemas from arbitrary internet peers in its hub/spoke topology — there is no way to guarantee the remote side won't produce a malicious or unsupported schema definition. A panic is the wrong failure mode for that threat model; an Err the caller can handle is correct.

All three sites converted to Err(AlkTypeError::Offset/Schema(...)):

  • offset_map.rs:277 — _ => Err(Offset { ... "unsupported AlkType kind for aligned offset computation" })
  • layout_builder.rs:285 — _ => Err(Offset { ... "unsupported AlkType kind for packed layout computation" })
  • materialize.rs:324 — _ => return Err(Schema(... "internal: union discriminator type N is not a supported byte discriminator (parse_discriminator should have rejected this)"))

The materialize.rs site uses Schema rather than Access because a wrong disc_type is a schema-authoring bug (the parse_discriminator validator should have caught it earlier), not a buffer-access error. The offset_map and layout_builder sites use Offset to match their surrounding error variants. The sibling sites in sequential_reader.rs::read_byte_discriminator (line 535) and tunion.rs::read_byte_discriminator (line 70) already returned Err(Schema(...)) — only materialize.rs was the holdout.

Note: the k if k.is_fixed_size() guard pattern in offset_map and layout_builder means the compiler cannot enforce exhaustiveness on these match arms (the guard catches new variants at runtime, not compile time). Converting the _ arm from unreachable! to Err is the defense-in-depth mitigation: a new AlkTypeKind variant added without updating these matches produces a handled error, not a panic. A future refactor could remove the is_fixed_size() guard and list all fixed-size kinds explicitly — that would restore compile-time exhaustiveness checking. Deferred to a separate cleanup pass.

Verified: no unreachable! remains in production code (the one remaining hit at offset_map.rs:688 is inside a #[test] fn, guarded by assert!(matches!(...)) on the line above).

After M1, M2, L1, L2, and L3, the remaining open findings (N1, N2) were resolved in the pre-publish docs sweep. All 7 findings are now closed. The crate is ready for the final sanity check and publish.