# Hygiene — Consolidate `_iso_ts_from_clock` into `helpers/iso_timestamps.py`

**Task**: AZ-526_hygiene_iso_ts_from_clock_consolidation
**Name**: Clock-injected ISO-timestamp helper consolidation
**Description**: Replace the four duplicated `_iso_ts_from_clock(clock)` definitions (two module-level free functions in `c2_vpr/{net_vlad,ultra_vpr}.py`, two instance methods in `c2_vpr/_faiss_bridge.py` and `c12_operator_orchestrator/operator_reloc_service.py`) with a single Layer-1 helper alongside `iso_ts_now()` in `src/gps_denied_onboard/helpers/iso_timestamps.py`. Closes cumulative review batches 46–48 Findings F1 (Medium / Maintainability) and F3 (Low / Maintainability).
**Complexity**: 2 points
**Dependencies**: AZ-508 (the `helpers/iso_timestamps.py` module must exist first), AZ-398 (Clock Protocol must exist — already shipped)
**Component**: helpers (epic AZ-264 / E-CC-HELPERS)
**Tracker**: AZ-526
**Epic**: AZ-264 (E-CC-HELPERS)

### Document Dependencies

- `_docs/03_implementation/cumulative_review_batches_46-48_cycle1_report.md` § F1 + F3 — the findings being closed.
- `_docs/02_document/module-layout.md` § `shared/helpers/iso_timestamps` — the helpers row that the new function will extend.
- `_docs/02_tasks/done/AZ-508_hygiene_iso_timestamps_consolidation.md` — the precedent task (Batch 48) that shipped `iso_ts_now()` and whose scope explicitly Excluded the Clock-injected variant.

## Problem

Four modules across two components define the same Clock-injected ISO-timestamp helper:

| File | Surface | Source format |
|------|---------|---------------|
| `src/gps_denied_onboard/components/c2_vpr/net_vlad.py` | module-level free function | `+00:00` suffix, 9-digit nanos |
| `src/gps_denied_onboard/components/c2_vpr/ultra_vpr.py` | module-level free function | `+00:00` suffix, 9-digit nanos |
| `src/gps_denied_onboard/components/c2_vpr/_faiss_bridge.py` | instance method on `FaissBridge` | `+00:00` suffix, 9-digit nanos |
| `src/gps_denied_onboard/components/c12_operator_orchestrator/operator_reloc_service.py` | instance method on `OperatorReLocService` | `+00:00` suffix, 9-digit nanos |

The bodies are byte-identical modulo `clock` parameter vs `self._clock`. Both `net_vlad.py` and `ultra_vpr.py` carry an inline comment saying "AZ-508 will consolidate the duplicate helpers across c2/c11/c12/c6" — but AZ-508 explicitly Excluded this variant. `_faiss_bridge.py` carries a meta-rule citation rejecting consolidation; that rejection is invalidated now that AZ-508 has established the helpers/iso_timestamps.py home.

AZ-339 (MegaLoc + MixVPR), AZ-340 (SelaVPR + EigenPlaces + SALAD), AZ-358 (C4 OpenCVGtsam pose), and AZ-389 (C5 orthorectifier) will each add 1–3 more copies unless consolidated first.

## Outcome

- The existing helper module `src/gps_denied_onboard/helpers/iso_timestamps.py` gains a second public function: `iso_ts_from_clock(clock: Clock) -> str`. Format `YYYY-MM-DDTHH:MM:SS.fffffffffZ` (9-digit nanosecond precision, `Z` suffix matching `iso_ts_now()` and the canonical FDR `_TS` fixture).
- The four duplicate definitions are deleted; consumers import from the helper. Module-level callers use `from … import iso_ts_from_clock as _iso_ts_from_clock`; the two instance methods are rewritten to one-line delegations (`return iso_ts_from_clock(self._clock)`).
- `_docs/02_document/module-layout.md` `shared/helpers/iso_timestamps` row is updated to mention the second function.
- A new AC test set is added to `tests/unit/test_az508_iso_timestamps.py` covering AC-1..AC-5 of this task.

## Scope

### Included

- Add to `src/gps_denied_onboard/helpers/iso_timestamps.py`:

  ```python
  from gps_denied_onboard.clock import Clock

  def iso_ts_from_clock(clock: Clock) -> str:
      """Return an RFC 3339 UTC timestamp built from `clock.time_ns()`.

      Format: ``YYYY-MM-DDTHH:MM:SS.fffffffffZ`` (9-digit nanosecond
      precision, ``Z`` suffix). Use when the timestamp must follow an
      injectable Clock (replay tests, deterministic fixtures);
      use ``iso_ts_now()`` otherwise.
      """
      ns = int(clock.time_ns())
      seconds, fraction_ns = divmod(ns, 1_000_000_000)
      dt = datetime.fromtimestamp(seconds, tz=timezone.utc)
      return f"{dt.strftime('%Y-%m-%dT%H:%M:%S')}.{fraction_ns:09d}Z"
  ```

- Re-export `iso_ts_from_clock` through `src/gps_denied_onboard/helpers/__init__.py` and add it to `__all__`.
- Migrate the four call sites:
  - `c2_vpr/net_vlad.py`: delete the free function; add `from gps_denied_onboard.helpers.iso_timestamps import iso_ts_from_clock as _iso_ts_from_clock` at the top.
  - `c2_vpr/ultra_vpr.py`: same.
  - `c2_vpr/_faiss_bridge.py`: rewrite `_iso_ts_from_clock(self)` to a one-line delegation `return iso_ts_from_clock(self._clock)`; remove the stale meta-rule comment block. Add the helper import at the top.
  - `c12_operator_orchestrator/operator_reloc_service.py`: same pattern as `_faiss_bridge.py`.
- Extend `tests/unit/test_az508_iso_timestamps.py` with new AC tests (AC-1..AC-5 below). Reuse the existing test file rather than creating a new one; the helpers/iso_timestamps.py module surface is jointly tested.
- Update `_docs/02_document/module-layout.md` `shared/helpers/iso_timestamps` row to describe both functions.

### Excluded

- Changing `Clock` Protocol or `WallClock` / `TlogDerivedClock` implementations.
- Migrating any other timestamp emitters that use `time.time_ns()` directly without going through `Clock`.
- Bumping FDR schema versions (AC-5 requires `test_az272_fdr_record_schema.py` to pass unmodified).
- Migrating timestamp callers in components that don't currently have a `_iso_ts_from_clock` helper.

## Acceptance Criteria

**AC-1: Helper exists at the canonical path**
Given a fresh checkout
When `from gps_denied_onboard.helpers.iso_timestamps import iso_ts_from_clock` is run
Then the import succeeds; the function is callable with a `Clock`-compatible argument

**AC-2: Output format matches the canonical Z-suffix nanosecond regex**
Given any `Clock` instance returning `time_ns()` value `N`
When `iso_ts_from_clock(clock)` is called
Then the output matches `^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{9}Z$` and is parseable into a UTC-aware datetime via `datetime.fromisoformat(value[:26] + "+00:00")` (note: stdlib `fromisoformat` doesn't accept 9-digit fractional seconds; tests truncate to 6 digits + offset for round-trip verification)

**AC-3: Nanosecond accuracy**
Given a stub `Clock` returning `time_ns() == 1_234_567_890_123_456_789`
When `iso_ts_from_clock(clock)` is called
Then the output ends in `.123456789Z` (the nanosecond fraction is preserved verbatim)

**AC-4: All four call sites migrated**
Given a `grep -rn "def _iso_ts_from_clock\|def iso_ts_from_clock" src/` after the task lands
When the search runs
Then matches appear only inside `src/gps_denied_onboard/helpers/iso_timestamps.py`; zero matches in `c2_vpr/`, `c12_operator_orchestrator/`, or any other component

**AC-5: FDR schema tests pass unmodified**
Given the existing `tests/unit/test_az272_fdr_record_schema.py` suite
When the suite runs after this task
Then every previously-passing test still passes; no test file is modified

**AC-6: AZ-270 + AZ-507 lints pass**
Given the helper lives at Layer 1 and only imports from `_types` / `clock` / stdlib
When `tests/unit/test_az270_compose_root.py::test_ac6` runs
Then the test passes (the lint walks `src/gps_denied_onboard/components/`; helper imports are allowed)

## Constraints

- The helper imports ONLY from stdlib (`datetime`) and `gps_denied_onboard.clock` (Layer 1). NO component imports.
- Output format `Z`-suffix nanosecond precision (`%Y-%m-%dT%H:%M:%S.{fraction_ns:09d}Z`). This intentionally differs from the existing `+00:00`-suffix output to align with `iso_ts_now()` and the canonical FDR `_TS` fixture (resolves cumulative-46-48 F3).
- The two instance-method call sites become one-line delegations; no behavior change at the call sites.
- Test additions go into the existing `tests/unit/test_az508_iso_timestamps.py`; do NOT create a separate test file (the helpers/iso_timestamps.py module surface is one cohesive Layer-1 concern).

## Risks & Mitigation

**Risk 1: Switching `+00:00` → `Z` changes FDR `ts` bit-shape for c2_vpr / c12 records**
- *Risk*: Downstream consumers parsing the FDR may have hardcoded `+00:00` expectations.
- *Mitigation*: The FDR `ts` field is typed `str` with no schema-level format validation (see `src/gps_denied_onboard/fdr_client/records.py` line 378). The canonical `_TS` test fixture already uses `Z`. AC-5 + the unchanged `test_az272_fdr_record_schema.py` is the safety net; if it fails, the consolidation must align to the existing FDR `_TS` fixture (= keep `Z`), confirming the choice.

**Risk 2: A future emitter forgets the helper exists and adds a 5th local copy**
- *Risk*: The pattern recurs once we add C3/C4 strategies in AZ-345..AZ-358.
- *Mitigation*: AZ-508's AST-walk test already asserts zero stray `iso_ts_now` definitions outside the helper module; extend it to also assert zero stray `iso_ts_from_clock` definitions. The combined assertion is the regression guard.

**Risk 3: Layer-1 helper depending on `Clock` introduces a cross-layer cycle**
- *Risk*: `helpers/*` imports from `clock/*`.
- *Mitigation*: Both `helpers/*` and `clock/*` are Layer 1 per `module-layout.md` Allowed Dependencies table. No cycle: `clock/interface.py` only imports `typing.Protocol`; the helper imports `clock.Clock` (interface only); no reverse path.

## Runtime Completeness

- **Named capability**: a Layer-1 helper `iso_ts_from_clock(clock: Clock) -> str` producing nanosecond-precision UTC ISO-8601 with `Z` suffix.
- **Production code that must exist**: real `iso_ts_from_clock` function alongside `iso_ts_now` in the helper module; real import + delegation in each of the four flagged modules.
- **Allowed external stubs**: none. Pure consolidation.
- **Unacceptable substitutes**: keeping one or more local definitions "for parity"; using `+00:00` suffix in the new helper (defeats the F3 alignment); changing the nanosecond precision (FDR records assume 9-digit subseconds).
