# Batch 22 — AZ-490 C5 set_takeoff_origin + bounded-delta gate

**Date**: 2026-05-12
**Tracker**: Jira AZ-490 (Epic AZ-260 / E-C5) — transitioned To Do → In Progress → Done.
**Cycle**: 1
**Status**: complete; 33 new unit tests green; full repo 750 passed / 2 skipped (pre-existing CI tooling skips).

## Scope landed

AZ-490 delivers ADR-010's airborne consumer of the operator-supplied takeoff
origin (the producer side landed in batch 21 as AZ-489). C5's `StateEstimator`
Protocol gains the pre-takeoff `set_takeoff_origin` entrypoint, both concrete
implementations (`GtsamIsam2StateEstimator` + `EskfStateEstimator`) honour it
with the same semantics, and the AZ-385 source-label state machine grows the
third — bounded-delta — clause of Principle #11. Four FDR record kinds
(`c5.cold_start_origin.set`, `c5.cold_start_origin.unavailable`,
`c5.gps_bounded_delta.accept`, `c5.gps_bounded_delta.reject`) are now part of
the AZ-272 schema and round-trip cleanly.

The composition-root F2 wiring of `manifest.takeoff_origin → estimator.set_takeoff_origin`
remains explicitly out of scope (owned by the AZ-381 owner as a follow-up commit
to keep this task focused on the C5-side contract per the spec).

### Public surface

* **`StateEstimator` Protocol** (`src/gps_denied_onboard/components/c5_state/interface.py`)
  gains `set_takeoff_origin(origin: LatLonAlt, sigma_horiz_m: float, sigma_vert_m: float) -> None`.
  Two-sigma signature replaces the contract's older single-`sigma_m` shape per
  the AZ-490 task spec — see "Decisions" below.
* **`GtsamIsam2StateEstimator`** seeds the iSAM2 graph with one
  `PriorFactorPose3` at `Pose3.Identity()` (operator origin BECOMES the local
  ENU (0,0,0) anchor) with diagonal sigmas
  `[5°, 5°, 5°, sigma_horiz_m, sigma_horiz_m, sigma_vert_m]` and drives a
  single `handle.update`.
* **`EskfStateEstimator`** zeros `_nominal_pos` and overwrites the position
  block of `_P` to `diag(sigma_horiz_m², sigma_horiz_m², sigma_vert_m²)`,
  defensively re-symmetrising afterwards.
* **`SourceLabelStateMachine.process_gps_sample(sample, smoother_estimate, now_ns)`**
  is the third clause of the spoof-promotion gate. Returns
  `BOUNDED_DELTA_SOFT` when the sample is within
  `spoof_promotion_bounded_delta_m` of the smoother estimate (one
  `c5.gps_bounded_delta.accept` FDR record); returns `BOUNDED_DELTA_REJECT`
  when outside the ring (one `c5.gps_bounded_delta.reject` FDR record + the
  dwell-time clause counter is reset, so the rejection counts against the
  existing AZ-385 promotion path); returns `None` when no smoother estimate
  is available yet (cold start). Distance is computed via
  `WgsConverter.horizontal_distance_m`, matching the WGS-84 geodesic
  (pyproj's ECEF chain) within sub-mm at the bounded-delta operating range.
* **Both estimators** expose `notify_gps_sample(sample: GpsSample) -> str | None`
  as the C8-inbound seam — looks up the smoother's current latlon
  (best-effort) and delegates to the source-label machine.
* **Errors** — `EstimatorAlreadyStartedError` is a new
  `StateEstimatorConfigError` subclass for the late-call path. Existing
  `except StateEstimatorConfigError` callers still catch it.
* **Config** — `C5StateConfig` gains `spoof_promotion_bounded_delta_m=200.0`,
  `default_takeoff_origin_sigma_horiz_m=5.0`, `default_takeoff_origin_sigma_vert_m=10.0`
  with `__post_init__` positivity validation matching the existing pattern.
* **DTO** — new `GpsSample` in `src/gps_denied_onboard/_types/fc.py` carries
  `LatLonAlt` + `captured_at: int`. Distinct from `GpsHealth` (which only
  carries the health enum); the bounded-delta gate needs the position.
* **WgsConverter** — new static `WgsConverter.horizontal_distance_m(a, b)`
  reuses the existing `latlonalt_to_local_enu` ECEF transform and returns
  `hypot(east, north)`. Geodetically correct (NOT haversine on
  equirectangular per AC-15).
* **FDR schema** — four new kinds registered in
  `KNOWN_PAYLOAD_KEYS` with explicit per-key allow-lists; AZ-268 contract
  test (`test_az272_fdr_record_schema.py`) was extended with their fixture
  payloads and now round-trips them.

### Cold-start ledger

* The cold-start window closes on the first `add_*` call (vio / pose_anchor /
  fc_imu) via the new `_close_cold_start_window` helper added to both
  estimators. `set_takeoff_origin` after the window closes raises
  `EstimatorAlreadyStartedError`.
* Exactly one `c5.cold_start_origin.set` FDR record is emitted per
  estimator: `source="manifest"` from `set_takeoff_origin`, or
  `source="fc_ekf"` from `_close_cold_start_window` if no operator origin
  was supplied (legacy AZ-419 fallback path, just newly logged per AC-13).
* Idempotency: re-calling `set_takeoff_origin` with byte-identical args is a
  no-op (no second FDR, no second prior). Re-calling with different args
  raises `StateEstimatorConfigError` naming both prev/new args.

### Doc updates

* `_docs/02_document/contracts/c5_state/state_estimator_protocol.md` —
  signature widened to two sigmas; Invariants 11a/11b/11c/11d/11e refined
  to match the implemented contract; Invariant 12 (bounded-delta gate)
  unchanged.
* `_docs/02_document/components/07_c5_state/description.md` — params
  column, state-management cold-start ladder, and error-handling table
  updated. New `EstimatorAlreadyStartedError` documented.
* `_docs/02_document/architecture.md` — ADR-010 narrative + `TakeoffOrigin`
  glossary line + Principle #14 narrative updated.
* `_docs/02_document/system-flows.md` — F2 sequence diagram + flow chart +
  data-table all carry the two-sigma signature.
* `_docs/02_document/glossary.md` — `Takeoff origin` entry updated.
* `_docs/02_document/components/11_c10_provisioning/description.md` —
  one prose mention updated.

## Tests

* `tests/unit/c5_state/test_az490_set_takeoff_origin.py` — 33 tests
  covering AC-1..AC-15 plus three additional edge cases (no-smoother
  bounded-delta returns None; ESKF `notify_gps_sample` happy path; protocol
  conformance for both impls). All pass.
* `tests/unit/c5_state/test_az381_state_protocol.py` — `_FakeEstimator`
  test fake updated with the new `set_takeoff_origin` method (isinstance
  check stays green).
* `tests/unit/c5_state/test_az385_source_label_spoof_gate.py` — fixture
  `_make_sm` now passes `spoof_promotion_bounded_delta_m=200.0` to the SM
  constructor; existing AC-1..AC-12 still pass (AC-14 of AZ-490 — additive
  clause).
* `tests/unit/test_az272_fdr_record_schema.py` — `_kind_payload` extended
  with the four new C5 kinds; the `KNOWN_KINDS`-parametrised
  round-trip test now covers them automatically.

Full repo: **750 passed / 2 skipped** (the two skips are pre-existing
CI-only tooling skips: `cmake` and `actionlint` not on PATH locally).

## Decisions

* **Two-sigma signature, not single-sigma**: the C5 contract's older
  signature (`sigma_m: float`) lost a useful axis — operator-supplied
  manifests typically distinguish horizontal (GPS) from vertical
  (barometric / GPS) uncertainty by an order of magnitude. The task spec's
  two-sigma form is more general and aligns with the new
  `default_takeoff_origin_sigma_{horiz,vert}_m` config defaults. The
  contract document was updated to match.
* **`EstimatorAlreadyStartedError` as a `StateEstimatorConfigError`
  subclass**, not a peer: existing `except StateEstimatorConfigError`
  callers should still catch it; a new exception name is justified by the
  semantic distinction (you called the method at the wrong time, vs. you
  called it with bad args), but the inheritance keeps the C5 error
  hierarchy three-rooted (Degraded / Fatal / Config).
* **`BOUNDED_DELTA_SOFT` is a private string constant**, not a new
  `PoseSourceLabel` enum value: `PoseSourceLabel` is a shared C4/C5 public
  surface; adding a value would ripple through every consumer (test
  fixtures, FDR `state.tick` schema, C8 outbound formatter) for a soft-
  admission outcome that doesn't change the externally observable pose
  provenance. The string constant is exposed from `_source_label_sm` for
  the ESKF / iSAM2 dispatch return value but never enters the public
  `EstimatorOutput.source_label` field.
* **`WgsConverter.horizontal_distance_m` instead of a new
  `vincenty_distance` method**: AC-15's text references "Vincenty
  distance" but pyproj's ECEF chain (which our `latlonalt_to_local_enu`
  already uses) matches Vincenty within sub-mm at the bounded-delta
  operating range (<= ~1 km), so the algorithmic family is geodetically
  correct. The new helper makes the intent explicit. AC-15 is satisfied
  against `pyproj.Geod.inv` as the WGS-84 reference (the test compares to
  the geodesic, not a haversine shortcut).
* **`GpsSample` DTO introduced now**, even though full inbound wiring from
  AZ-391 is deferred. The bounded-delta method needs a typed handle on
  `(LatLonAlt, captured_at)` — extending `GpsHealth` to carry a position
  was rejected as it conflates "health enum" with "geographic sample".
  The `notify_gps_sample(sample)` method on both estimators is the
  composition-root seam; AZ-391 plumbing arrives in a later task.
* **Bounded-delta reject also resets the dwell-time clause**: a wildly-off
  GPS sample is a strong signal the FC stream is unreliable, even when
  `gps_health == STABLE_NON_SPOOFED`. Resetting `_gps_health_stable_since_ns`
  on reject means the AZ-385 spoof gate can't re-promote off the back of
  bad data. AC-10 verifies this.

## Self-review findings

* **Low / Code-quality**: `_validate_takeoff_origin_args` is duplicated
  between `gtsam_isam2_estimator.py` (static method) and `eskf_baseline.py`
  (module-level helper). Same source code. Can be lifted to a shared
  `_validation.py` if the duplication grows. Surgical for now —
  intentionally not refactored to keep the AZ-490 diff scoped.

No High / Critical findings. PASS_WITH_WARNINGS verdict.

## Out-of-scope (explicit)

* Composition-root F2 wiring of
  `manifest.takeoff_origin → estimator.set_takeoff_origin` (owned by the
  AZ-381 owner as a follow-up commit per the task spec).
* AZ-391 inbound-path wiring of `GpsSample` (FC GPS observations currently
  flow as `GpsHealth` only; `GpsSample` is a forward-compatible DTO that a
  later task will populate from the C8 inbound subscription).
* C10 Manifest schema extension to include `takeoff_origin` (owned by
  AZ-323 / AZ-324; the consumer side is in place).
* AZ-419's BBT cold-start path validation (FT-P-11 — owned separately).

## Files touched

```
src/gps_denied_onboard/_types/fc.py
src/gps_denied_onboard/components/c5_state/__init__.py
src/gps_denied_onboard/components/c5_state/_source_label_sm.py
src/gps_denied_onboard/components/c5_state/config.py
src/gps_denied_onboard/components/c5_state/errors.py
src/gps_denied_onboard/components/c5_state/eskf_baseline.py
src/gps_denied_onboard/components/c5_state/gtsam_isam2_estimator.py
src/gps_denied_onboard/components/c5_state/interface.py
src/gps_denied_onboard/fdr_client/records.py
src/gps_denied_onboard/helpers/wgs_converter.py
tests/unit/c5_state/test_az381_state_protocol.py
tests/unit/c5_state/test_az385_source_label_spoof_gate.py
tests/unit/c5_state/test_az490_set_takeoff_origin.py    [new]
tests/unit/test_az272_fdr_record_schema.py
_docs/02_document/architecture.md
_docs/02_document/components/07_c5_state/description.md
_docs/02_document/components/11_c10_provisioning/description.md
_docs/02_document/contracts/c5_state/state_estimator_protocol.md
_docs/02_document/glossary.md
_docs/02_document/system-flows.md
_docs/03_implementation/batch_22_cycle1_report.md             [new]
```
