diff --git a/.planning/AC-TRACEABILITY.md b/.planning/AC-TRACEABILITY.md
new file mode 100644
index 0000000..c2a5299
--- /dev/null
+++ b/.planning/AC-TRACEABILITY.md
@@ -0,0 +1,52 @@
+# AC Traceability Matrix
+
+> Auto-generated by `scripts/gen_ac_traceability.py`. Do not edit by hand.
+> Run `python scripts/gen_ac_traceability.py` to regenerate after AC doc or test edits.
+
+**ACs declared in acceptance_criteria.md:** 39
+**ACs covered by at least one test:** 14
+**ACs deferred to hardware:** 4
+
+## AC -> Test mapping
+
+| AC ID | Test count | Tests | Status |
+|-------|-----------|-------|--------|
+| AC-1.1 | 5 | `tests/test_acceptance.py::test_ac1_normal_flight`
`tests/test_acceptance.py::test_ac5_sustained_throughput`
`tests/test_accuracy.py::test_pct_within_50m_with_sat_corrections`
`tests/test_accuracy.py::test_passes_acceptance_criteria_full_pass`
`tests/test_accuracy.py::test_passes_acceptance_criteria_accuracy_fail` | OK |
+| AC-1.2 | 2 | `tests/test_accuracy.py::test_pct_within_20m_with_sat_corrections`
`tests/test_accuracy.py::test_passes_acceptance_criteria_full_pass` | OK |
+| AC-1.3 | 1 | `tests/test_accuracy.py::test_vo_drift_under_100m_over_1km` | OK |
+| AC-1.4 | 2 | `tests/test_acceptance.py::test_ac4_user_anchor_fix`
`tests/test_processor_pipe.py::test_create_flight_initialises_eskf` | OK |
+| AC-2.1a | 2 | `tests/test_acceptance.py::test_ac2_tracking_loss_and_recovery`
`tests/test_accuracy.py::test_confidence_high_after_fresh_satellite` | OK |
+| AC-2.1b | 0 | _none_ | **ORPHAN -- no test** |
+| AC-2.2 | 1 | `tests/test_accuracy.py::test_covariance_shrinks_after_satellite_update` | OK |
+| AC-3.1 | 0 | _none_ | **ORPHAN -- no test** |
+| AC-3.2 | 0 | _none_ | **ORPHAN -- no test** |
+| AC-3.3 | 1 | `tests/test_acceptance.py::test_ac6_graph_optimization_convergence` | OK |
+| AC-3.4 | 3 | `tests/test_acceptance.py::test_ac2_tracking_loss_and_recovery`
`tests/test_mavlink.py::test_reloc_request_triggered_after_3_failures`
`tests/test_sitl_integration.py::test_reloc_request_after_3_failures_with_sitl` | OK |
+| AC-3.5 | 0 | _none_ | **ORPHAN -- no test** |
+| AC-4.1 | 3 | `tests/test_acceptance.py::test_ac3_performance_per_frame`
`tests/test_accuracy.py::test_per_frame_latency_under_400ms`
`tests/test_accuracy.py::test_passes_acceptance_criteria_latency_fail` | OK |
+| AC-4.2 | 0 | _none_ | **ORPHAN -- no test** |
+| AC-4.3 | 20 | `tests/test_gps_input_encoding.py::test_gps_input_lat_lon_encoded_as_deg_e7`
`tests/test_gps_input_encoding.py::test_gps_input_lat_lon_offset_from_enu_position`
`tests/test_gps_input_encoding.py::test_gps_input_alt_in_meters_msl`
`tests/test_gps_input_encoding.py::test_gps_input_velocity_enu_to_ned_conversion`
`tests/test_gps_input_encoding.py::test_gps_input_satellites_visible_synthetic_10`
`tests/test_gps_input_encoding.py::test_gps_input_fix_type_high_confidence_is_3d`
`tests/test_gps_input_encoding.py::test_gps_input_fix_type_medium_confidence_is_3d`
`tests/test_gps_input_encoding.py::test_gps_input_fix_type_low_confidence_no_fix`
`tests/test_gps_input_encoding.py::test_gps_input_fix_type_failed_no_fix`
`tests/test_gps_input_encoding.py::test_gps_input_accuracy_from_covariance`
`tests/test_gps_input_encoding.py::test_gps_input_hdop_vdop_clamped_to_min`
`tests/test_gps_input_encoding.py::test_confidence_tier_mapping_complete`
`tests/test_mavlink.py::test_confidence_to_fix_type`
`tests/test_mavlink.py::test_eskf_to_gps_input_position`
`tests/test_mavlink.py::test_eskf_to_gps_input_lon`
`tests/test_sitl_integration.py::test_sitl_tcp_port_reachable`
`tests/test_sitl_integration.py::test_pymavlink_connection_to_sitl`
`tests/test_sitl_integration.py::test_gps_input_accepted_by_sitl`
`tests/test_sitl_integration.py::test_mavlink_bridge_start_stop_with_sitl`
`tests/test_sitl_integration.py::test_gps_input_rate_at_least_5hz` | OK |
+| AC-4.4 | 5 | `tests/test_acceptance.py::test_ac1_normal_flight`
`tests/test_acceptance.py::test_ac4_user_anchor_fix`
`tests/test_acceptance.py::test_ac5_sustained_throughput`
`tests/test_processor_pipe.py::test_mavlink_state_pushed_per_frame`
`tests/test_sitl_integration.py::test_gps_input_rate_at_least_5hz` | OK |
+| AC-4.5 | 0 | _none_ | **ORPHAN -- no test** |
+| AC-5.1 | 0 | _none_ | **ORPHAN -- no test** |
+| AC-5.2 | 2 | `tests/test_mavlink.py::test_reloc_request_triggered_after_3_failures`
`tests/test_sitl_integration.py::test_reloc_request_after_3_failures_with_sitl` | OK |
+| AC-5.3 | 0 | _none_ | **ORPHAN -- no test** |
+| AC-6.1 | 0 | _none_ | **ORPHAN -- no test** |
+| AC-6.2 | 0 | _none_ | **ORPHAN -- no test** |
+| AC-6.3 | 6 | `tests/test_schemas.py::TestGPSPoint::test_valid`
`tests/test_schemas.py::TestGPSPoint::test_lat_out_of_range`
`tests/test_schemas.py::TestGPSPoint::test_lon_out_of_range`
`tests/test_schemas.py::TestGPSPoint::test_serialization_roundtrip`
`tests/test_schemas.py::TestWaypoint::test_valid`
`tests/test_schemas.py::TestWaypoint::test_confidence_out_of_range` | OK |
+| AC-7.1 | 0 | _none_ | **ORPHAN -- no test** |
+| AC-7.2 | 0 | _none_ | **ORPHAN -- no test** |
+| AC-8.1 | 0 | _none_ | **ORPHAN -- no test** |
+| AC-8.2 | 0 | _none_ | **ORPHAN -- no test** |
+| AC-8.3 | 0 | _none_ | **ORPHAN -- no test** |
+| AC-8.4 | 0 | _none_ | **ORPHAN -- no test** |
+| AC-8.5 | 0 | _none_ | **ORPHAN -- no test** |
+| AC-8.6 | 0 | _none_ | **ORPHAN -- no test** |
+| AC-NEW-1 | 0 | _none_ | DEFERRED (hardware) |
+| AC-NEW-2 | 1 | `tests/test_sitl_integration.py::test_gps_input_accepted_by_sitl` | OK |
+| AC-NEW-3 | 0 | _none_ | DEFERRED (hardware) |
+| AC-NEW-4 | 0 | _none_ | **ORPHAN -- no test** |
+| AC-NEW-5 | 0 | _none_ | DEFERRED (hardware) |
+| AC-NEW-6 | 0 | _none_ | **ORPHAN -- no test** |
+| AC-NEW-7 | 0 | _none_ | DEFERRED (hardware) |
+| AC-NEW-8 | 0 | _none_ | **ORPHAN -- no test** |