# Batch 87 — AZ-436 + AZ-437 + AZ-438 + AZ-439 (Security NFTs)

**Tracker**: AZ-436, AZ-437, AZ-438, AZ-439
**Tasks**: 4 tasks / 16 complexity points (5 + 3 + 3 + 5)
**Date**: 2026-05-17
**Verdict**: PASS_WITH_WARNINGS
**Review**: `_docs/03_implementation/reviews/batch_87_review.md`
**Cumulative review**: `_docs/03_implementation/reviews/cumulative_review_batches_85_87.md`

## Scope

- **AZ-436 / NFT-SEC-01 (AC-NEW-9)** — N synthetic flights with 1-5 % poisoned tiles; aggregate `false_trust_count ≤ N × 1e-6`; zero-tolerance default. Canonical (ardupilot, okvis2) at N=1000 by default; `E2E_NFT_SEC_01_RELEASE_GATE=1` unlocks full N=10000 × matrix.
- **AZ-437 / NFT-SEC-02 + NFT-SEC-05 (AC-NEW-10)** — Two scenarios sharing the egress-observation pattern: NFT-SEC-02 verifies 0 packets to non-`e2e-net` over 5-min Derkachi replay; NFT-SEC-05 verifies DNS-blackhole sidecar absorbs probes + UDP-53 silence.
- **AZ-438 / NFT-SEC-03 (AC-NEW-11)** — AP-only; three sub-cases (unsigned / wrong-key / replayed-tlog) each yield `BAD_SIGNATURE` STATUSTEXT ≤500 ms + no position drift. iNav SKIPs.
- **AZ-439 / NFT-SEC-04 (RESTRICT-CVE-1)** — Probe scenario (always-run): cve-jpeg-fixture does not crash SUT + records deterministic decode-success or frame-decode-error. ASan-fuzz scenario (release-gate `E2E_NFT_SEC_04_RELEASE_GATE=1`): ≥4 h, 0 ASan findings, ≥1000 corpus inputs (informational).

## Files

### Created (13 files)

- `e2e/runner/helpers/cache_poisoning_evaluator.py` — N-flight aggregate verdict + per-flight poison-ratio + defense-layer-coverage + rejection-reason vocabulary checks.
- `e2e/runner/helpers/egress_observer.py` — before/after counter snapshots, `NoEgressReport` + `DnsBlackholeReport` + 5-outcome DNS lookup classifier.
- `e2e/runner/helpers/mavlink_signing_evaluator.py` — per-sub-case rejection STATUSTEXT match (BAD_SIGNATURE + documented variants) + position-drift verdict + AC-1 iNav-SKIP companion logic.
- `e2e/runner/helpers/cve_probe_evaluator.py` — FDR-survival + deterministic-outcome classifier; rejects silent drops as defense-bypass.
- `e2e/runner/helpers/asan_fuzz_evaluator.py` — line-level ASan-finding classifier (8 categories + OTHER_FINDING fallback) + duration gate + corpus-floor informational check.
- `e2e/tests/security/test_nft_sec_01_cache_poisoning.py` — NFT-SEC-01 scenario.
- `e2e/tests/security/test_nft_sec_02_no_egress.py` — NFT-SEC-02 scenario.
- `e2e/tests/security/test_nft_sec_03_mavlink_signing.py` — NFT-SEC-03 scenario (AP-only).
- `e2e/tests/security/test_nft_sec_04_opencv_cve.py` — NFT-SEC-04 probe scenario (always-run).
- `e2e/tests/security/test_nft_sec_04_asan_fuzz.py` — NFT-SEC-04 fuzz scenario (release-gate).
- `e2e/tests/security/test_nft_sec_05_dns_blackhole.py` — NFT-SEC-05 scenario.
- `e2e/_unit_tests/helpers/test_cache_poisoning_evaluator.py` — 16 unit tests.
- `e2e/_unit_tests/helpers/test_egress_observer.py` — 14 unit tests.
- `e2e/_unit_tests/helpers/test_mavlink_signing_evaluator.py` — 18 unit tests.
- `e2e/_unit_tests/helpers/test_cve_probe_evaluator.py` — 11 unit tests.
- `e2e/_unit_tests/helpers/test_asan_fuzz_evaluator.py` — 16 unit tests.
- `_docs/03_implementation/reviews/batch_87_review.md` — per-batch code review.
- `_docs/03_implementation/reviews/cumulative_review_batches_85_87.md` — K=3 window cumulative review.
- `_docs/LESSONS.md` — agent-behaviour lesson (Jira transition IDs).

### Modified

- `e2e/_unit_tests/test_directory_layout.py` — registered 11 new paths (5 helpers + 6 scenarios).

## Test Results

Per-batch unit tests:

```
$ pytest e2e/_unit_tests/helpers/test_cache_poisoning_evaluator.py \
         e2e/_unit_tests/helpers/test_egress_observer.py \
         e2e/_unit_tests/helpers/test_mavlink_signing_evaluator.py \
         e2e/_unit_tests/helpers/test_cve_probe_evaluator.py \
         e2e/_unit_tests/helpers/test_asan_fuzz_evaluator.py \
         e2e/_unit_tests/test_directory_layout.py
================ 215 passed in 0.25s ================
```

Full unit-test suite (regression check, run from workspace root):

```
$ pytest e2e/_unit_tests/
================ 1151 passed in 137.86s (0:02:17) ================
```

Scenario collection (36 cases — 6 scenarios × 6 (fc_adapter × vio_strategy) variants):

```
$ pytest e2e/tests/security/ --collect-only -p no:csv --evidence-out=/tmp/e2e-test-evidence
collected 36 items
```

Scenario smoke (all 36 skip cleanly with diagnostic messages):

```
36 skipped in 0.11s
```

Skip breakdown:
- 12 skip-on-`vins_mono` (conftest research-build-only rule from D-C1-1-SUB-A).
- 5 skip-on-canonical-only for NFT-SEC-01 (AC-4 default + the matching `vins_mono`-skipped vins variants).
- 6 skip-on-iNav for NFT-SEC-03 (AC-1).
- 4 skip-on-release-gate for NFT-SEC-04 ASan-fuzz.
- 9 skip-on-`sitl_replay_ready=False` (no `E2E_SITL_REPLAY_DIR` locally).

## AC Verification

### AZ-436 / NFT-SEC-01

| AC | Coverage |
|----|----------|
| AC-1 N flights complete | `len(flights) < NFT_SEC_01_CI_MIN_FLIGHTS` gate + scenario `flight_count` NFR record |
| AC-2 poisoned-tile production | `passes_ratio` + `passes_layer_coverage` + `passes_rejection_reason_vocabulary` (3 sub-asserts) |
| AC-3 false-trust budget | `passes_budget` (zero-tolerance default — `count == 0`) + scenario `total_false_trust` / `budget` NFR records |
| AC-4 parameterization | canonical-only default + `E2E_NFT_SEC_01_RELEASE_GATE=1` for full matrix |

### AZ-437 / NFT-SEC-02 + NFT-SEC-05

| AC | Coverage |
|----|----------|
| NFT-SEC-02 AC-1 egress counter == 0 | `NoEgressReport.passes` + scenario AC-1 assert |
| NFT-SEC-05 AC-2 sidecar healthy | `DnsBlackholeReport.sidecar_healthy` + scenario AC-2 assert |
| NFT-SEC-05 AC-3a lookup fails | `passes_lookup` (NXDOMAIN / timeout / no-servers / other-failure) + scenario AC-3a assert |
| NFT-SEC-05 AC-3b UDP-53 silent | `passes_udp_silence` + scenario AC-3b assert |
| AC-4 parameterization | conftest matrix |

### AZ-438 / NFT-SEC-03

| AC | Coverage |
|----|----------|
| AC-1 iNav SKIP | scenario-top guard on `fc_adapter == "inav"` |
| AC-2/3/4 per-sub-case rejection ≤500 ms + no position update | per-sub-case `passes_rejection` + `passes_no_position_update` (3 ACs × 2 sub-asserts) |
| AC-5 vio_strategy parameterization | conftest matrix |

### AZ-439 / NFT-SEC-04

| AC | Coverage |
|----|----------|
| AC-1a probe no crash | `passes_no_crash` + scenario AC-1a assert |
| AC-1b probe graceful outcome | `passes_graceful_outcome` + scenario AC-1b assert (rejects silent drops) |
| AC-2 ASan fuzz 0 findings ≥4 h | `passes_findings` + `passes_duration` + scenario AC-2 assert |
| AC-3 ASan fuzz ≥1000 corpus | `reached_corpus_floor` (informational only per spec; recorded in CSV, not asserted) |
| AC-4 parameterization | probe = full matrix; fuzz = ardupilot + per-vio only (justified inline to avoid duplicating a 4 h run) |

`traces_to` markers:
- NFT-SEC-01: `AC-NEW-9,AC-1,AC-2,AC-3,AC-4`
- NFT-SEC-02: `AC-NEW-10,AC-1,AC-4`
- NFT-SEC-03: `AC-NEW-11,AC-1,AC-2,AC-3,AC-4,AC-5`
- NFT-SEC-04 probe: `RESTRICT-CVE-1,AC-1,AC-4`
- NFT-SEC-04 fuzz: `RESTRICT-CVE-1,AC-2,AC-3,AC-4`
- NFT-SEC-05: `AC-NEW-10,AC-2,AC-3,AC-4`

## Code Review

**Verdict**: PASS_WITH_WARNINGS — 0 Critical, 0 High, 0 Medium, 5 Low.

- **F1 (Low / Maintainability — carry-over)**: `write_csv_evidence` boilerplate continues to grow (13 helpers).
- **F2 (Low / Spec-Gap)**: DNS-blackhole sidecar referenced by NFT-SEC-05 but not deployed in `e2e/docker/docker-compose.test.yml`.
- **F3 (Low / Spec-Gap)**: AP MAVLink 2.0 signing handshake (AZ-416) must be triggered by AZ-595 fixture builder before NFT-SEC-03 replay can run end-to-end.
- **F4 (Low / Maintainability — carry-over)**: `_resolve_fixture_path` duplicated across 6 new scenarios.
- **F5 (Low / Design-aligned)**: NFT-SEC-04 ASan-fuzz AC-3 corpus floor is informational-only per task spec.

Full review: `_docs/03_implementation/reviews/batch_87_review.md`.

## Cumulative Review (Batches 85-87 — K=3 Window)

**Verdict**: PASS_WITH_WARNINGS. 5 cross-batch findings:

- **CR-F1 (Medium / Maintainability)**: 13 helpers each duplicate the `write_csv_evidence` pattern. Recommended PBI: shared `csv_evidence_writer.py` (3 pts).
- **CR-F2 (Medium / Maintainability)**: 13 scenarios each duplicate `_resolve_fixture_path`. Recommended PBI: shared `fixture_path.resolve()` (2 pts).
- **CR-F3 (Low / Spec-Gap)**: AZ-595 fixture builder doesn't exist as a tracked task; needs to materialize 13 JSON contracts. Recommended PBI: 5 pts.
- **CR-F4 (Low / Infrastructure-Gap)**: DNS-blackhole sidecar absent. Recommended PBI: 3 pts.
- **CR-F5 (Informational)**: full unit-test suite (1151 tests, ~138 s) runs green from workspace root.

Full cumulative review: `_docs/03_implementation/reviews/cumulative_review_batches_85_87.md`.

## Production Dependencies

Surfaced for the traceability matrix + AZ-595:

1. **AZ-595 (fixture builder)**: emit `nft_sec_01_cache_poisoning.json` (per-flight cache + poisoned-tile slate + runner-collected `false_trust_events` + `rejection_reasons` counter); `nft_sec_02_no_egress.json` (before/after Docker network stats snapshots); `nft_sec_03_mavlink_signing.json` (3 injection timestamps + AP STATUSTEXT + GLOBAL_POSITION_INT captures); `nft_sec_04_cve_probe.json` (probe_injected_at_ms + per-frame FDR record sequence); `nft_sec_04_asan_fuzz.json` (ASan stderr log + duration + corpus size); `nft_sec_05_dns_blackhole.json` (sidecar_healthy + lookup_outcome + UDP-53 before/after).
2. **AZ-444 (Tier-2 runner) — optional**: NFT-SEC-04 ASan-fuzz at Tier-2 (Jetson) per the same release-gate flag.
3. **e2e infrastructure**: DNS-blackhole sidecar service in `docker-compose.test.yml` per `environment.md`.
4. **AZ-416 (FT-P-09-AP) — already in `done/`**: AP MAVLink 2.0 signing handshake must run before AZ-595 generates the NFT-SEC-03 replay payload.
5. **SUT**: outbound `source_label` MUST carry `tile_id` for NFT-SEC-01 false-trust attribution; FDR MUST emit deterministic decode-success/error per frame for NFT-SEC-04 silent-drop detection.

## Architecture Compliance

- All new files under `e2e/`, owned by the Blackbox Tests cross-cutting component per `_docs/02_document/module-layout.md`.
- No imports from `src/gps_denied_onboard` (verified — only `runner.helpers.sitl_observer`, stdlib).
- No new cyclic dependencies. New evaluators are leaves of the import DAG.
- No new infrastructure libraries (stdlib `csv`, `dataclasses`, `enum`, `re`, `pathlib`, `math` only).

## Sub-step Trace

Phases executed per `implement/SKILL.md`:
- phase 5 (load-spec) → 4 task specs read
- phase 6 (implement-tasks-sequentially) → 5 helpers + 6 scenarios + 5 unit-test files for all 4 tasks
- phase 7 (verify-ac-coverage) → ACs traced above
- phase 8 (code-review) → batch_87_review.md (PASS_WITH_WARNINGS, 5 Low)
- phase 8.5 (cumulative-review) → cumulative_review_batches_85_87.md (PASS_WITH_WARNINGS, 5 cross-batch findings)
- phase 11 (commit-batch) → next.

## Notes on this batch

- A Jira transition mistake was made early in this batch (used `id=31` for "In Progress" but `id=31` in this workflow = "Done"). Caught by the mandatory read-back gate, corrected by re-transitioning to id `21` (verified-correct via `getTransitionsForJiraIssue` lookup). Lesson recorded in `_docs/LESSONS.md`. No code or git artifacts were affected — only the tracker state, which is fully restored.
