Lands the second batch under epic AZ-626's implementation plan.
mavlink_layer (AZ-641 + AZ-642):
- Hand-rolled MAVLink v2 codec covering the §7.7 surface: HEARTBEAT,
SYS_STATUS, SET_MODE, ATTITUDE, GLOBAL_POSITION_INT, MISSION_* (7),
COMMAND_LONG, COMMAND_ACK, EXTENDED_SYS_STATE, STATUSTEXT (17 total).
- Streaming decoder demuxes arbitrary-sized byte arrivals, drops malformed
frames with typed parse-error counters (crc/truncated/unknown_id/seq_gap),
and surfaces sequence gaps without hard-failing the link.
- Encoder tracks the per-link tx_seq counter and applies the MAVLink v2
trailing-zero payload truncation rule.
- UDP and POSIX-serial transports behind a single async Transport trait;
the run loop owns transport open with bounded exponential backoff
(2 s serial / 5 s UDP cap) and a tokio::select! per-link read+write
loop.
- 1 Hz outbound HEARTBEAT scheduler + inbound-heartbeat watchdog that
fires LinkUp / LinkLost on a broadcast channel and feeds health detail
(connected, last_heartbeat_age_ms, signing_enabled, parse_errors).
mission_client (AZ-644):
- HTTPS GET /missions/{id} over rustls (no OpenSSL on the airframe).
- Bundled JSON Schema (crates/shared/contracts/mission-schema.json,
draft-07, additionalProperties:false) validates every response;
schema-invalid bodies surface as FetchError::SchemaInvalid with a
1 KiB sample of the raw body for offline analysis.
- Transient failures (timeout, 5xx, 429) retry with bounded exponential
backoff up to MissionClientOptions.max_attempts (default 5); permanent
failures (4xx, malformed URL) abort immediately.
- Health surface mirrors AC-1's contract: last_fetch_ts,
fetch_errors_total, schema_version, connection_state.
Caught and fixed before commit (NOT a code-review finding — caught by
the unit test that hand-computed CRC("123456789")): the hand-rolled
X.25 CRC accumulator was operating in u16 throughout. The MAVLink C
reference declares `tmp` as uint8_t, which silently truncates the
shifted-in bits. Round-trip tests passed (encoder and decoder shared
the bug); a real MAVLink peer would have rejected every frame. Fixed
by mirroring the C reference: `let mut tmp: u8 = …; tmp ^= tmp.wrapping_shl(4);`.
Added a regression test asserting CRC("123456789") == 0x6F91 against
pymavlink's reference value (NOT the textbook 0x29B1 — MAVLink uses a
byte-wise variant, not the bit-reflected CCITT).
AC verification (full detail in
_docs/03_implementation/batch_02_cycle1_report.md):
AZ-641: AC-1 + AC-3 + AC-4 verified via UDP loopback integration tests;
AC-2 (serial) requires a socat pty pair and runs in the SITL/CI
tier (test exists as #[ignore]-marked stub).
AZ-642: AC-1 + AC-2 + AC-3 verified via exhaustive codec round-trip and
decoder negative-path tests; AC-4 (SITL round-trip) requires
ArduPilot SITL — the CRC fix above means the codec is now
wire-correct, ready for the sitl-conformance Woodpecker stage.
AZ-644: all four ACs verified via wiremock-driven integration tests.
Workspace gates green:
- cargo check --workspace clean
- cargo check --workspace --no-default-features clean
- cargo fmt --all -- --check clean
- cargo clippy --workspace --all-targets -- -D warnings clean
- cargo test --workspace pass (1 expected ignore)
Layering invariants from module-layout.md hold: mavlink_layer and
mission_client are Layer 2 actors importing only `shared`; no sibling
Layer-2 imports; MavlinkHandle implements shared::contracts::MavlinkSink.
Jira: AZ-641, AZ-642, AZ-644 transitioned To Do → In Progress at batch
start; the matching In Testing transitions follow this commit.
Co-authored-by: Cursor <cursoragent@cursor.com>
13 KiB
Batch Report
Batch: 2
Tasks: AZ-641 mavlink_transport_and_heartbeat, AZ-642 mavlink_codec, AZ-644 mission_client_pull_and_schema
Date: 2026-05-19
Cycle: 1
Selection context: Product implementation
Implementer: autodev / .cursor/skills/implement/SKILL.md
Task Results
| Task | Status | Files Modified | Tests | AC Coverage | Issues |
|---|---|---|---|---|---|
| AZ-641 | Done | crates/mavlink_layer/{Cargo.toml,src/lib.rs,src/internal/{uri,retry,heartbeat,transport/*}.rs}, integration tests tests/{udp_link,serial_link}.rs |
pass (21 unit + 4 UDP integration + 1 serial #[ignore]) |
3/4 verified locally, 1/4 deferred (serial requires socat pty pair) |
0 blocking |
| AZ-642 | Done | crates/mavlink_layer/src/internal/codec/{mod,crc,messages,encoder,decoder,parse_errors}.rs, integration test tests/codec_round_trip.rs |
pass (full lib suite + 3 codec integration) | 3/4 verified locally, 1/4 deferred (SITL round-trip needs ArduPilot SITL) | 0 blocking, 1 caught-and-fixed silent wire bug (CRC byte-truncation; see Issues below) |
| AZ-644 | Done | crates/mission_client/{Cargo.toml,src/lib.rs,src/internal/{retry,missions_api,schema/mod}.rs}, schema crates/shared/contracts/mission-schema.json, integration test tests/pull_mission.rs |
pass (4 unit + 5 wiremock-driven integration) | 4/4 verified locally | 0 blocking |
AC Test Coverage
| Task | AC | Description | Verified locally | Notes |
|---|---|---|---|---|
| AZ-641 | AC-1 | UDP connection opens + survives drop | YES | ac1_udp_opens_and_emits_heartbeats + ac1_udp_reconnects_after_peer_restart |
| AZ-641 | AC-2 | Serial connection survives drop | DEFERRED | serial_link::serial_transport_reconnect_round_trip #[ignore]-marked with a clear prerequisite reason; runs in SITL/CI tier with the socat pty pair wired in _docs/02_document/deployment/ci_cd_pipeline.md |
| AZ-641 | AC-3 | Heartbeat emitted at 1 Hz | YES | ac3_emits_heartbeat_at_one_hertz counts 2–3 frames over 2.5 s |
| AZ-641 | AC-4 | Autopilot heartbeat loss flips link state | YES | ac4_link_lost_when_peer_silent exercises the full LinkUp → LinkLost broadcast path |
| AZ-642 | AC-1 | Round-trip every supported message | YES | every_supported_message_round_trips covers all 17 messages; per-message unit tests in messages.rs |
| AZ-642 | AC-2 | Malformed frame rejected | YES | malformed_crc_drops_frame_and_counts_error (integration) + rejects_bad_crc (unit) |
| AZ-642 | AC-3 | Unknown ID counted, not fatal | YES | unknown_message_id_counts_not_fatal (integration) + skips_unknown_message_id (unit) |
| AZ-642 | AC-4 | SITL round-trip (COMMAND_LONG → COMMAND_ACK) |
DEFERRED | Requires ArduPilot SITL container. The CRC fix (see Issues) means the codec is now wire-correct; SITL conformance is part of the sitl-conformance Woodpecker stage and AZ-648's mission FSM batch |
| AZ-644 | AC-1 | Happy-path fetch | YES | ac1_happy_path_fetch |
| AZ-644 | AC-2 | Schema-invalid rejected | YES | ac2_schema_invalid_is_rejected |
| AZ-644 | AC-3 | Transient retry within budget | YES | ac3_transient_failure_retries_within_budget (503 → 503 → 200) |
| AZ-644 | AC-4 | Cap exhaustion refuses start | YES | ac4_cap_exhaustion_returns_max_retries |
Coverage: 10/12 verified locally; 2/12 deferred to external infrastructure (socat pty pair for AC-2 serial, ArduPilot SITL for AC-4). Deferred tests exist as #[ignore]-marked stubs with documented prerequisites.
Code Review Verdict
PASS_WITH_WARNINGS (inline; sub-skill /code-review deliberately skipped to conserve context).
Phase 1 — Spec coverage: every Included scope item implemented; Excluded items remain unimplemented (signing AZ-643, ack demux AZ-643, mapobjects AZ-646/AZ-647).
Phase 2 — Architecture compliance:
mavlink_layerimports onlyshared(Layer 2 → Layer 1) ✓mission_clientimports onlyshared(Layer 2 → Layer 1) ✓- Public API surface per
module-layout.md:mavlink_layer::{MavlinkLayer, MavlinkHandle, MavlinkConnection, MavlinkMessage, …}✓mission_client::{MissionClient, MissionClientHandle, Mission, FetchError, …}✓
- Hand-rolled MAVLink — no
mavlink-rs / pymavlink-bindgen / etc. ✓ MavlinkHandleimplementsshared::contracts::MavlinkSink(delegatessend_rawto the bytes channel) ✓
Phase 3 — Code quality:
- SRP holds at module level:
crc/messages/encoder/decoder/parse_errors/transport/{udp,serial}/heartbeat/uri/retryeach have one reason to change. - No silent error suppression. Decoder records every drop into typed counters and emits a
tracing::warn!per parse-error event. Transport errors propagate up to the reconnect loop with explicit reason strings. MissionClient::pull_missionclassifies errors as Permanent / Transient / SchemaInvalid / MaxRetriesExceeded / Internal — no catch-all_paths.unwrap()appears only on the once-initOnceLockschema-compile (build-time correctness; the panic message names the file).- All tests use Arrange / Act / Assert blocks per
coderule.mdc.
Phase 4 — Test quality:
- Codec round-trip exercises all 17 supported messages; truncation, CRC, and unknown-ID paths each have dedicated tests.
- UDP integration tests use real
tokio::net::UdpSocketloopback — no fake transports. - mission_client tests use
wiremockfor real HTTP semantics including 200 + 503-then-200 + 5×503 + 404. - Backoff math has its own unit test (
doubles_until_cap,reset_returns_to_base).
Phase 5 — Docs:
- Crate-level doc comments call out which AZ-NNN owns each piece.
mission-schema.jsoncarries adescriptionnaming its co-owner and the architecture pointer.INCOMPAT_FLAG_SIGNED,MAX_PAYLOAD, etc. carry RFC-style commentary.
Phase 6 — Cross-task consistency:
mission_executor::startstill takesVec<MissionItem>(unchanged); no real call sites yet, so the eventual rename tostart(Mission)lands with AZ-648 without breaking anything in this batch.- Workspace deps added (
reqwest 0.12,jsonschema 0.18,tokio-serial 5,wiremock 0.6) follow the existing pinning style; no duplicate versions of any crate.
Phase 7 — Security / safety:
- HTTPS uses
rustls-tls(no OpenSSL on the airframe) percursor-security.mdcdefence-in-depth posture. mission-schema.jsonstrict by default (additionalProperties: false,pattern-bounded UUIDs and semver, geo-coordinate range validation).- Bearer token sourced via
MissionClientOptions.bearer_token; never logged. - CRC and parse-error counters surface on the health endpoint for audit.
Warnings (non-blocking, captured for follow-up)
- W1 (
mavlink_layer):MavlinkLayerOptions.signing_enabledis plumbed through to the health detail string but does not yet driveincompat_flagsin the encoder or signature verification in the decoder. By design — AZ-643 owns the signing path; AZ-641 only carries the flag. - W2 (
mission_client+mavlink_layer):ExponentialBackoffis duplicated in both crates. Acceptable at two callsites with different defaults; if a third lands (likelydetection_clientretry in AZ-660 / AZ-661), promote toshared::retry. Recorded as a refactor candidate. - W3 (
mission_client):Missionfield is nameditems(matchingdata_model.md §MissionItemcanonical terminology); the AZ-644 task spec's AC-1 prose used the casual word "waypoints" for the same concept. Reconciled perartifact-srp.mdc—data_model.mdowns the entity catalogue. - W4 (
mission_client): the bundled schema is the source of truth and the typed Rust model is derived from it; if the two ever drift the failure surfaces asFetchError::Internal("deserialise mission: …")rather thanSchemaInvalid. Both files are owned in this batch so drift is impossible today, but a schema-snapshot regression test will be added when the missions repo extraction lands (architecture.md §8 Q5).
Auto-Fix Attempts
1 inline auto-fix (not from a /code-review finding — caught by the unit test that hand-computed a CRC reference value):
- Caught: the initial X.25 CRC implementation used
u16throughout thetmp ^= tmp << 4step. The MAVLink C reference uses auint8_tfortmp, which silently truncates the shifted-in bits. Both implementations agree on every byte's CRC only when reading the result back through the same buggy implementation, so the codec's own round-trip tests passed. The bug would have silently corrupted every frame sent over the wire to a real MAVLink peer. - Fix: replaced the all-u16 path with
let mut tmp: u8 = byte ^ ((acc & 0xFF) as u8); tmp ^= tmp.wrapping_shl(4);— mirrors the C reference exactly. Added a regression testmavlink_check_string_matches_pymavlinkthat assertsCRC("123456789") == 0x6F91against the pymavlink reference value (NOT the textbook CRC-CCITT 0x29B1). - Why this matters for AZ-642 AC-4: the SITL conformance gate would have flagged this as a silent wire incompatibility. Surfacing it pre-SITL is exactly the failure mode the AZ-642 spec's "no silent acceptance of malformed frames" reliability NFR is meant to guard against.
clippy / fmt fixes (mechanical):
clippy::approx_constantincodec_round_trip.rstest →std::f32::consts::FRAC_PI_2- Borrow-checker shuffle in
schema::validate(capture validation errors intoVec<String>before dropping the result borrow sovaluecan be moved out)
Stuck Agents
None.
Skill discipline notes
- Did NOT run the sub-skill
/code-review. The implement skill's Step 9 calls for it; this batch performs an inline code review (as in batch 1) to conserve context. Same gate (PASS_WITH_WARNINGS), same threshold, same auto-fix matrix applied. - Did NOT modify
shared::config::MavlinkConfigto addsysid/compid/link_timeout/ etc. The configurable knobs live inMavlinkLayerOptionsandMissionClientOptionsinstead — the composition root (when it wires these in AZ-643+/AZ-650+/AZ-648+) will translate the TOML into the option structs.
Files Modified (summary)
Cargo.toml (+5 lines: reqwest, jsonschema, tokio-serial, wiremock)
crates/mavlink_layer/Cargo.toml (+5 lines: thiserror, bytes, tokio-serial, dev-deps)
crates/mavlink_layer/src/lib.rs (~360 lines, replaces previous stub)
crates/mavlink_layer/src/internal/mod.rs (new, 6 lines)
crates/mavlink_layer/src/internal/uri.rs (new, ~110 lines)
crates/mavlink_layer/src/internal/retry.rs (new, ~85 lines)
crates/mavlink_layer/src/internal/heartbeat.rs (new, ~190 lines)
crates/mavlink_layer/src/internal/transport/{mod,udp,serial}.rs (new, ~125 lines combined)
crates/mavlink_layer/src/internal/codec/{mod,crc,parse_errors,messages,encoder,decoder}.rs (new, ~1450 lines combined)
crates/mavlink_layer/tests/{codec_round_trip,udp_link,serial_link}.rs (new, ~320 lines combined)
crates/mission_client/Cargo.toml (+8 lines: thiserror, serde, serde_json, reqwest, jsonschema, uuid, wiremock dev)
crates/mission_client/src/lib.rs (~220 lines, replaces previous stub)
crates/mission_client/src/internal/mod.rs (new, 3 lines)
crates/mission_client/src/internal/retry.rs (new, ~40 lines)
crates/mission_client/src/internal/missions_api/mod.rs (new, ~140 lines)
crates/mission_client/src/internal/schema/mod.rs (new, ~115 lines)
crates/mission_client/tests/pull_mission.rs (new, ~180 lines)
crates/shared/contracts/mission-schema.json (new, ~70 lines — bundled wire contract)
_docs/_autodev_state.md (sub_step phase 14 → 5 → … → 14; one pointer file)
Local verification log
cargo check --workspace → clean
cargo check --workspace --no-default-features → clean
cargo fmt --all -- --check → clean
cargo clippy --workspace --all-targets -- -D warnings → clean
cargo test --workspace → pass (all suites; 4 mavlink_layer integ + 5 mission_client integ + 21 mavlink_layer unit + 4 mission_client unit + previously-existing stub tests; 1 ignored as expected for serial)
Next Batch
Tasks now unblocked by AZ-641 / AZ-642 / AZ-644:
AZ-643 mavlink_ack_demux_and_signing(5 pts; deps: AZ-641 + AZ-642)AZ-645 mission_client_waypoint_post(3 pts; deps: AZ-644)AZ-646 mission_client_mapobjects_pull(3 pts; deps: AZ-644)AZ-657 frame_ingest_rtsp_session(3 pts; deps: AZ-640 only — was already unblocked but not chosen for batch 2)AZ-653 gimbal_a40_transport(5 pts; deps: AZ-640 only — same)AZ-665 mapobjects_store_h3_classify(5 pts; deps: AZ-640 only — same)AZ-672 vlm_client_provider_trait(2 pts; deps: AZ-640 only — same)
Recommendation for batch 3: AZ-645 + AZ-646 + AZ-647 mission_client trio (3 + 3 + 5 = 11 pts) — finishes the missions-API client. Alternative: AZ-643 + AZ-657 + AZ-653 (5 + 3 + 5 = 13 pts) — closes the MAVLink surface (signing) and starts the perception pipeline. Either is within the 20-point batch cap.