[AZ-680] [AZ-681] operator_bridge command dispatch + safety lane

Add the operator-command dispatcher behind a typed CommandAck:
60 s per-command-id idempotency cache, surfaced-POI registry with
unknown_poi_id + expired gates, BIT-degraded ack severity check, and
SafetyOverride forwarding to mission_executor with structured audit
log (redacts signature + session_token).

Cross-layer wiring goes through three new traits in shared::contracts
(ScanCommandRouter, MissionSafetyRouter, BitReportSeverityLookup) so
operator_bridge stays free of direct scan_controller / mission_executor
imports. scan_controller::ScanControllerHandle implements the scan
router; a new mission_executor::SafetyDispatchHandle wraps the BIT
ack channel + battery monitor handle and implements the safety router;
BitControllerHandle gains a bounded (16-entry) report-severity cache
for the lookup trait.

scan_controller also picks up ConfirmPoi handling: PoiQueue::confirm
removes the entry and SubmitOutcome::Confirmed carries the typed
(target_mgrs, target_class) hint for AZ-684/AZ-686 downstream.

Tests: 9 new integration tests in operator_bridge/tests/dispatcher.rs
cover AZ-680 AC-1..AC-5 + AZ-681 AC-1..AC-4. scan_controller adds 2
ConfirmPoi tests. All modified-crate suites green; one pre-existing
mission_executor state-machine test flake (already documented in
_docs/_process_leftovers) updated to note ac1 also affected.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-20 17:32:38 +03:00
parent aa4282f9f8
commit c4eff40dbc
24 changed files with 2017 additions and 53 deletions
@@ -0,0 +1,78 @@
# Operator Command Dispatch (confirm / decline / target-follow start/release) + Idempotency
**Task**: AZ-680_operator_bridge_command_dispatch
**Name**: Dispatch validated operator commands to scan_controller + per-command-id idempotency
**Description**: After `validate()` (task 39), dispatch the command to `scan_controller`: confirm → `(target_mgrs, target_class)`, decline → `(MGRS, class_group)` for IgnoredItem append, target-follow start/release → state transition. Per-command-id idempotency cache (60 s window) so re-transmits get the cached result rather than double-acting.
**Complexity**: 3 points
**Dependencies**: AZ-640_initial_structure, AZ-678_operator_bridge_command_auth
**Component**: operator_bridge
**Tracker**: AZ-680
**Epic**: AZ-628
## Problem
Operator commands can be re-transmitted over a lossy modem link; the autopilot must not double-act. Each command carries a unique `command_id`; results are cached for 60 s. The dispatch step validates the command is for a known POI (or a target-follow session, BIT report, or safety-override scope) before forwarding to `scan_controller`.
## Outcome
- `CommandDispatcher::dispatch(validated_cmd) -> CommandAck` routes the command into `scan_controller`'s API and returns an ack.
- Idempotency cache `command_id → CommandAck` with 60 s TTL; cache-hit returns the cached ack and does NOT re-dispatch.
- POI-id-bound commands validated against currently surfaced POI set; unknown POI id → `Ack::Error { reason: unknown_poi_id }`.
- Deadline expiration handled: command past deadline → `Ack::Error { reason: expired }`.
- Health: `commands_in_flight`, `decision_latency_p50/p99`.
## Scope
### Included
- Dispatch switch on command kind (`Confirm | Decline | TargetFollowStart | TargetFollowRelease`).
- Idempotency cache with TTL.
- POI id validity check.
- Deadline check.
### Excluded
- Auth/replay (task 39).
- BIT-degraded ack + safety-override (task 42; separate command kinds with separate flow).
- POI queue mechanics (task 44).
## Acceptance Criteria
**AC-1: Confirm forwards target hint**
Given a validated `Confirm { command_id, poi_id }` for a currently-surfaced POI
When `dispatch(cmd)` runs
Then `scan_controller::on_confirm(target_mgrs, target_class)` is invoked exactly once; ack `Ok` returned; cache populated.
**AC-2: Re-transmit returns cached ack**
Given the same `command_id` re-arrives within 60 s of the first dispatch
When `dispatch(cmd)` runs
Then the cached ack is returned; `scan_controller::on_confirm` is NOT invoked again.
**AC-3: Unknown POI id rejected**
Given a `Confirm` with `poi_id` not in the surfaced set
When `dispatch(cmd)` runs
Then `Ack::Error { reason: unknown_poi_id }` returned; nothing dispatched.
**AC-4: Expired POI rejected**
Given a `Confirm` whose deadline has passed
When `dispatch(cmd)` runs
Then `Ack::Error { reason: expired }` returned; nothing dispatched.
**AC-5: Decline appends IgnoredItem via scan_controller**
Given a validated `Decline { command_id, poi_id }`
When `dispatch(cmd)` runs
Then `scan_controller::on_decline(mgrs, class_group)` invoked exactly once.
## Non-Functional Requirements
**Performance**
- Dispatch overhead: ≤2 ms p99.
- Operator command → autopilot effect: ≤1 s under normal modem (end-to-end).
## Contract
- Canonical typed model: `data_model.md §OperatorCommand`, `§POI`.
## Runtime Completeness
- **Named capability**: operator command dispatch + idempotency.
- **Production code that must exist**: real dispatch switch; real TTL cache; real validity + deadline checks.
- **Unacceptable substitutes**: no idempotency cache (the operator UI WILL retransmit on flaky modem) is unacceptable.
@@ -0,0 +1,77 @@
# BIT-DEGRADED Acknowledgement + Safety-Override Command Path
**Task**: AZ-681_operator_bridge_safety_and_bit_ack
**Name**: Forward signed BIT-degraded ack + safety-override commands to mission_executor
**Description**: Forward signed BIT-degraded acknowledgement commands (F9) and signed safety-override commands (F10: lost-link / battery suppression) to `mission_executor`. Severity check: operator MAY ack a DEGRADED but MUST NEVER ack a FAIL.
**Complexity**: 3 points
**Dependencies**: AZ-640_initial_structure, AZ-678_operator_bridge_command_auth, AZ-650_mission_executor_bit_f9, AZ-652_mission_executor_safety_and_resume
**Component**: operator_bridge
**Tracker**: AZ-681
**Epic**: AZ-628
## Problem
Two operator-command kinds bypass `scan_controller` and go straight to `mission_executor`: BIT-DEGRADED acknowledgement (gating takeoff after a non-FAIL BIT result), and safety-override (suppressing a lost-link RTL or battery-RTL trigger, scoped + bounded by `architecture.md` F10). Both MUST be signed (already enforced by task 39). The BIT severity check must reject any attempt to ack a FAIL.
## Outcome
- `SafetyCommandDispatcher::dispatch(validated_cmd) -> CommandAck` routes:
- `BitDegradedAck { bit_report_id }``mission_executor::on_bit_degraded_ack(bit_report_id)`; rejected if report severity = FAIL.
- `SafetyOverride { scope, duration }``mission_executor::on_safety_override(scope, duration)`.
- Severity check on BIT ack: looks up the originating `BitReport`; if `severity = Fail` returns `Ack::Error { reason: cannot_acknowledge_fail }` without dispatching.
- Idempotency cache (shared with task 41).
- Health: `safety_overrides_active`, `bit_ack_rejections_total{reason}`.
## Scope
### Included
- Dispatch switch for `BitDegradedAck` and `SafetyOverride`.
- Severity check against BIT report cache.
- Audit log entry for every safety command (signed, redacted payload).
### Excluded
- Auth (task 39).
- POI commands (task 41).
- The BIT machinery itself (lives in `mission_executor` task 11/AZ-650).
- The safety-override enforcement / scope-clamping (lives in `mission_executor` task 13/AZ-652).
## Acceptance Criteria
**AC-1: BIT-DEGRADED ack succeeds**
Given a validated `BitDegradedAck { bit_report_id: X }` where the BIT report severity = `Degraded`
When `dispatch(cmd)` runs
Then `mission_executor::on_bit_degraded_ack(X)` invoked exactly once; ack `Ok` returned.
**AC-2: BIT-FAIL ack rejected**
Given a validated `BitDegradedAck { bit_report_id: X }` where the BIT report severity = `Fail`
When `dispatch(cmd)` runs
Then `Ack::Error { reason: cannot_acknowledge_fail }` returned; `mission_executor::on_bit_degraded_ack` is NOT invoked; `bit_ack_rejections_total{reason: cannot_acknowledge_fail}` increments.
**AC-3: Safety-override forwards with scope + duration**
Given a validated `SafetyOverride { scope: BatteryRtl, duration_secs: 60 }`
When `dispatch(cmd)` runs
Then `mission_executor::on_safety_override(BatteryRtl, 60)` invoked exactly once; ack `Ok` returned; an audit log entry exists (operator id, scope, duration, ts).
**AC-4: Audit log redacts secrets**
Given any dispatched safety command
When the audit log entry is written
Then the entry contains the command id, timestamp, operator id, scope/duration — but NEVER the raw signature bytes or session token.
## Non-Functional Requirements
**Performance**
- Dispatch overhead: ≤2 ms p99.
**Security**
- Severity check is mandatory; no path bypasses it.
- Audit log entry is the only persistent trace of safety commands; redaction is mandatory.
## Contract
- Canonical typed model: `data_model.md §OperatorCommand`, `§BitReport`.
## Runtime Completeness
- **Named capability**: BIT-degraded ack + safety-override dispatch with severity gate + audit log.
- **Production code that must exist**: real severity check; real audit log writer (file or structured logger).
- **Unacceptable substitutes**: accepting a FAIL ack "because the signature was valid" defeats the whole F9 gate and is unacceptable.