[AZ-645] [AZ-646] [AZ-647] mission_client: middle-waypoint POST + mapobjects pull/push
ci/woodpecker/push/build-arm Pipeline failed

Batch 3 of greenfield Step 7 — mission_client epic AZ-638 close-out.

AZ-645 (Middle-waypoint POST)
- post_middle_waypoint(mission_id, &Mission) -> Result<MissionUpdateAck, PostError>
- Bounded retry (default 3 attempts) shared with the rest of missions_api
- Health: last_middle_waypoint_post_status (ok/error)

AZ-646 (Pre-flight MapObjects pull)
- pull_mapobjects(mission_id) -> Result<MapObjectsBundle, PullError>
- Schema-validated against bundled shared/contracts/mapobjects-bundle.json
- Typed errors: Unreachable / SchemaInvalid / MaxRetriesExceeded / Internal
- Health: mapobjects_pull_state, last_mapobjects_pull_ts

AZ-647 (Post-flight push + durable disk queue)
- push_mapobjects_diff(mission_id, MapObjectsDiff) -> PushReport
- recover_pending_pushes() -> Vec<PushReport> for crash recovery
- Write-ahead atomic-rename persistence under ${state_dir}/mapobjects_push/
- Per-endpoint independent retry: observations + ignored_items
- Partial success rewrites the disk file with only the failing portion
- Health: mapobjects_push_pending, last_push_ts, per-endpoint last error

Infrastructure
- Schemas: shared/contracts/mapobjects-{bundle,observations,ignored}.json
- Restructured schema/ into mission.rs + mapobjects.rs sub-modules
- New mapobjects_sync/ (pull, push, queue)
- workspace dep tempfile=3; mission_client dev-deps add tempfile + chrono

Tests
- 12/12 ACs verified locally (4 AZ-645 + 4 AZ-646 + 5 AZ-647)
- mission_client suite: 15 unit + 18 integration = 33 tests pass
- AZ-646 AC-4 proxy: 1000-object + 1000-ignored bundle within 30s
- AZ-647 AC-5 proxy: 5000-obs + 500-ignored push within 2min

Code review verdict: PASS_WITH_WARNINGS (inline). Cumulative review
(K=3 trigger) PASS_WITH_WARNINGS — full report in
_docs/03_implementation/cumulative_review_batches_01-03_cycle1_report.md.

Open follow-ups (non-blocking):
- module-layout.md: rename push_mapobjects -> push_mapobjects_diff (Step 13)
- ExponentialBackoff still duplicated across crates; promote to shared::retry
  when the third caller lands (likely detection_client AZ-660/661)
- state_dir default is relative; composition root must override

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-19 12:54:15 +03:00
parent 1c993d86b3
commit 0a87c0f716
25 changed files with 2911 additions and 233 deletions
@@ -1,64 +0,0 @@
# Middle-Waypoint POST
**Task**: AZ-645_mission_client_waypoint_post
**Name**: Middle-waypoint POST to missions API
**Description**: POST the updated mission (with operator-confirmed middle waypoint inserted) to the external `missions` API; bounded retry; surface failure to `mission_executor`.
**Complexity**: 2 points
**Dependencies**: AZ-640_initial_structure, AZ-644_mission_client_pull_and_schema
**Component**: mission_client
**Tracker**: AZ-645
**Epic**: AZ-638
## Problem
When the operator confirms a POI, `scan_controller` hands a middle-waypoint hint to `mission_executor`, which computes the patched mission (`current_position → middle_waypoint → resume_original_route`). That patched mission must be POSTed to the external `missions` API for persistence and traceability. If the POST fails, the executor decides whether to halt, RTL, or continue with the in-memory mission — `mission_client` only surfaces the failure.
## Outcome
- `MissionClient::post_middle_waypoint(mission_id, patched_mission) -> Result<MissionUpdateAck, PostError>` performs a `POST /missions/{id}/middle-waypoint` (exact path per `../_docs/02_missions.md`) and awaits an ack.
- Bounded exponential backoff on transient failure (default 3 attempts).
- On final failure returns a typed error; never silent.
- Health field `last_middle_waypoint_post_status` updated.
## Scope
### Included
- POST endpoint call with the patched mission body.
- Bounded retry on 5xx / timeout.
- Error surface to caller.
### Excluded
- The decision to RTL on failure (`mission_executor`).
- Recomputing the patched mission (`mission_executor`).
## Acceptance Criteria
**AC-1: Happy path POST**
Given a fixture missions API that accepts the POST and returns `200`
When `post_middle_waypoint("M1", patched)` is called
Then it returns `Ok(MissionUpdateAck { ... })` within ≤2 s and `health.last_middle_waypoint_post_status = "ok"`.
**AC-2: Transient failure retries**
Given the API returns `503` once then `200`
When the call is made
Then it returns `Ok` on the second attempt.
**AC-3: Cap exhaustion bubbles error**
Given the API returns `500` for all 3 default attempts
When the call is made
Then it returns `Err(MaxRetriesExceeded)` and the error is surfaced to the caller; no silent absorption.
## Non-Functional Requirements
**Performance**
- Single happy-path POST completes in ≤2 s on healthy connectivity.
**Reliability**
- Bounded backoff; no infinite retry.
## Runtime Completeness
- **Named capability**: middle-waypoint POST against the external `missions` API.
- **Production code that must exist**: real HTTPS POST.
- **Allowed external stubs**: `wiremock`/`mockito` for tests.
- **Unacceptable substitutes**: swallowing the error and proceeding is not acceptable.
@@ -1,76 +0,0 @@
# MapObjects Pre-Flight Pull
**Task**: AZ-646_mission_client_mapobjects_pull
**Name**: Pre-flight MapObjects GET + cached-fallback handshake
**Description**: After mission fetch succeeds, GET `/missions/{id}/mapobjects` (and `/ignored` if separated). Surface the bundle to `mapobjects_store`. On failure, surface BIT degradation — operator must acknowledge cached fallback or abort. Never silent.
**Complexity**: 3 points
**Dependencies**: AZ-640_initial_structure, AZ-644_mission_client_pull_and_schema
**Component**: mission_client
**Tracker**: AZ-646
**Epic**: AZ-638
## Problem
The MapObjects working copy is hydrated pre-flight from the central `missions` API. The pull must complete before `mission_executor` proceeds past `BIT_OK`. On pull failure the system must NOT silently proceed; instead, `mission_executor`'s BIT (F9) surfaces a degraded state — the operator either acknowledges cached fallback (signed acknowledgement per Q9) or aborts.
## Outcome
- `MissionClient::pull_mapobjects(mission_id) -> Result<MapObjectsBundle, PullError>` performs a `GET /missions/{id}/mapobjects` (and `/ignored` if the API splits them) and returns a typed `MapObjectsBundle { map_objects, ignored_items, fetched_at, schema_version, fallback_used: bool }`.
- On 200, the bundle is handed to `mapobjects_store` for hydration; `mapobjects_pull_state = synced`.
- On error or timeout, `pull_state = failed`; the typed error is surfaced to `mission_executor` (F9 BIT degrades, never silent).
- Health fields: `mapobjects_pull_state`, `last_mapobjects_pull_ts`.
## Scope
### Included
- GET endpoint(s) call.
- Schema validation of the bundle (using the shared MapObjects schema in `shared/contracts/`).
- Cached-fallback semantics — the **cache** itself lives in `mapobjects_store` (task 28); this task only knows to set `fallback_used = true` if it uses cached on operator ack.
- Health surface fields above.
### Excluded
- The cache storage itself (lives in `mapobjects_store`).
- Operator-acknowledgement flow (`operator_bridge`).
- BIT orchestration (`mission_executor`).
## Acceptance Criteria
**AC-1: Happy path pull**
Given a fixture API that returns a schema-valid MapObjects bundle
When `pull_mapobjects("M1")` is called
Then it returns `Ok(bundle)`, `pull_state = synced`, and the bundle reaches `mapobjects_store` for hydration.
**AC-2: Schema-invalid is rejected**
Given the API returns a 200 with a missing required field
When `pull_mapobjects("M1")` is called
Then it returns `Err(SchemaInvalid)` and `pull_state = failed`; no silent acceptance.
**AC-3: Network failure surfaces to F9**
Given the API is unreachable
When `pull_mapobjects("M1")` is called
Then it returns `Err(Unreachable)`, `pull_state = failed`, and the error is observable by `mission_executor`'s BIT path.
**AC-4: 30 km × 30 km area completes within budget**
Given a fixture bundle the size of a 30 km × 30 km mission area
When the pull is performed on a 100 Mbps loopback link
Then the call completes in ≤30 s.
## Non-Functional Requirements
**Performance**
- ≤30 s for a 30 km × 30 km mission area on healthy connectivity (per `description.md §8`).
**Reliability**
- Never silent on failure.
## Contract
- MapObjects bundle schema: `shared/contracts/mapobjects-bundle.json`. Owner: `../_docs/02_missions.md` §7.13 extension.
- Canonical typed model: `data_model.md §MapObjectsBundle`.
## Runtime Completeness
- **Named capability**: HTTPS GET against the central MapObjects extension + schema validation.
- **Production code that must exist**: real HTTPS GET; real schema validator.
- **Allowed external stubs**: `wiremock`/`mockito`.
- **Unacceptable substitutes**: skipping schema validation in production.
@@ -1,84 +0,0 @@
# MapObjects Post-Flight Push + Durable Queue
**Task**: AZ-647_mission_client_mapobjects_push
**Name**: Post-flight MapObjects push with durable queue and crash-recovery push
**Description**: On `mission_executor` terminal state, drain `mapobjects_store`'s pending diff and POST to `/missions/{id}/mapobjects` + `/missions/{id}/mapobjects/ignored`. Independent retry per endpoint. Persist pending diff on disk for 24 h durable retry. At startup, replay any non-empty pending diff from a previously terminated mission BEFORE BIT for any new mission begins.
**Complexity**: 5 points
**Dependencies**: AZ-640_initial_structure, AZ-644_mission_client_pull_and_schema, AZ-646_mission_client_mapobjects_pull
**Component**: mission_client
**Tracker**: AZ-647
**Epic**: AZ-638
## Problem
The full pass diff (NEW / MOVED / EXISTING / REMOVED-candidate observations + IgnoredItem appends) must reach the central API after the mission ends. In-flight central writes are forbidden (Frozen choice 6 — `architecture.md §7.3`). The post-flight push must survive transient failure (independent retry per endpoint), persistent failure (operator-visible warning + manual replay), and crash mid-mission (next-boot push of pending diff). The durable queue is the disk-backed safety net.
## Outcome
- `MissionClient::push_mapobjects_diff(mission_id, diff) -> PushReport` posts the observations and ignored-items independently; partial success does not roll back the successful endpoint.
- The pending diff is persisted on disk at `${state_dir}/mapobjects_push/<mission_id>.json` BEFORE the push starts (write-ahead).
- Per-endpoint bounded exponential backoff (24 h durable retry window; configurable).
- Persistent failure: `sync_state = degraded`; operator-visible warning; entry stays on disk for manual replay.
- At startup, if `${state_dir}/mapobjects_push/` has any non-empty file, run the push for those missions BEFORE BIT for any new mission begins (crash-recovery path).
## Scope
### Included
- Two POST endpoints, called independently with separate retry/backoff state.
- Write-ahead persistence of the pending diff before the network call.
- Crash-recovery sweep at startup.
- `PushReport { observations: PerEndpointStatus, ignored: PerEndpointStatus }`.
- Health surface: `mapobjects_push_pending`, `last_push_ts`, per-endpoint last error.
### Excluded
- Building the pending diff (`mapobjects_store` — task 28 owns `pending_observations` + `pending_ignored`).
- Choosing what's a terminal state (`mission_executor`).
- Operator UI for the manual-replay warning (`operator_bridge` / Ground Station).
## Acceptance Criteria
**AC-1: Happy path push**
Given the mission ended with N observations and M ignored items
When `push_mapobjects_diff("M1", diff)` is called and both endpoints return 200
Then both succeed, the disk file is cleared, and `sync_state = synced`.
**AC-2: Partial success — independent retry**
Given `/mapobjects` returns 200 and `/mapobjects/ignored` returns 503
When the push runs
Then the observations endpoint is reported success, the ignored endpoint is queued for retry, and the disk file retains ONLY the ignored portion.
**AC-3: Persistent failure persists for manual replay**
Given both endpoints return 503 for all 24 h of bounded retry
When the retry window closes
Then `sync_state = degraded`, the disk file remains intact, and a manual-replay warning is observable in `health()`.
**AC-4: Crash-recovery push at startup**
Given a previous run terminated with a non-empty disk file at `${state_dir}/mapobjects_push/M0.json`
When the process starts a new run for mission `M1`
Then the push for `M0` is attempted before BIT begins for `M1`; the order is observable via logs.
**AC-5: 60-min mission push within budget**
Given a fixture pass diff sized for a 60-min mission
When the push is performed on a 100 Mbps loopback link
Then both endpoints complete in ≤2 min.
## Non-Functional Requirements
**Performance**
- ≤2 min for a 60-min mission's pass diff (per `description.md §8`).
**Reliability**
- 24 h durable retry window.
- Crash-mid-mission: nothing is lost on disk.
## Contract
- MapObjects POST schemas: `shared/contracts/mapobjects-observations.json` and `shared/contracts/mapobjects-ignored.json`. Owner: `../_docs/02_missions.md` §7.13 extension.
- Canonical typed model: `data_model.md §MapObjectObservation`, `§IgnoredItem`.
## Runtime Completeness
- **Named capability**: durable on-disk queue + post-flight push to the central `missions` API.
- **Production code that must exist**: real disk write-ahead (atomic rename); real HTTPS POST; real backoff state machine; real crash-recovery sweep.
- **Allowed external stubs**: `wiremock`/`mockito` for tests; `tempfile` for the disk-queue tests.
- **Unacceptable substitutes**: an in-memory-only queue is not acceptable (crash recovery requires disk).