Skip to content

fix(coordinator): require an authenticated location before clearing decrypt budget - #1117

Merged
jleinenbach merged 4 commits into
1.7.2from
fix/codex-182-metadata-only-decrypt-proof
Jun 18, 2026
Merged

jleinenbach merged 4 commits into
1.7.2from
fix/codex-182-metadata-only-decrypt-proof

Conversation

@jleinenbach

@jleinenbach jleinenbach commented Jun 18, 2026 •

Copy link
Copy Markdown
Owner

Summary

Addresses the Codex review on PR #182 (commit 5f83158b): the shared
consecutive-decrypt-failure budget that gates re-authentication was cleared on
any non-empty decode result, including a metadata_only sentinel row that
proves no successful decrypt.

The bug

async_decrypt_location_response_locations returns a single metadata_only
sentinel row
when no encrypted report decrypts but the secrets bundle still
yields key material (e.g. a phone without a fresh fix). That row is non-empty
but carries only key material, not the result of an authenticated E2EE
decrypt. Two automatic paths treated its non-emptiness as positive proof of the
account-wide key:

  • background push (Auth/fcm_receiver_ha.py): _note_decrypt_success_for_entry
    fired after the if not locations guard.
  • poll cycle (coordinator/polling.py): cycle_had_successful_decrypt was
    set for any truthy location (a metadata_only row reaches it via the
    _select_best_location fallback).

Report-less metadata_only pushes/polls interleaved with real decrypt failures
could keep a stale E2EE key below the reauth threshold, leaving the account
stuck with no location updates and no reauth prompt. Codex flagged the
background path; the poll path had the same defect (breadth sweep).

The fix

A shared predicate is_real_location_record() in decrypt_locations.py
(the sentinel's producer, the single source of truth for the metadata_only
contract; metadata_only=True is set in exactly one place). Both success
signals are now gated on it:

  • background: if any(is_real_location_record(r) for r in locations): _note_decrypt_success_for_entry(...)
  • poll: if is_real_location_record(location): cycle_had_successful_decrypt = True

A genuine decrypted record still clears the budget; the metadata still flows
downstream unchanged. Manual locate stays upward-only (only ever increments,
never resets — unchanged).

Tests

  • New: a metadata_only result does not clear the budget on the background
    path (and the metadata still passes through).
  • New: a metadata_only poll cycle does not reset the decrypt-failure counter
    (modelled on the existing idle-cycle test).
  • New: parametrized unit test for is_real_location_record (real / False /
    None flag / sentinel / empty / None).
  • Mutation-checked: reverting either gate, or the predicate, turns the matching
    test red.
  • ruff check + mypy --strict (sources + tests) green; broader regression
    green; changed lines covered.

Why a separate PR

Codex reviewed PR #182 (the 1.7.2 → upstream PR). This fix branches from
jleinenbach:1.7.2 and targets 1.7.2, so after merge it flows into #182
automatically.


Review note (Codex round 7)

Codex flagged that the round‑6 predicate is_real_location_record() used a
denylist on a single sentinel (metadata_only is not True). SEMANTIC rows
skip the crypto path (decrypted_location=b"", continue before
authentication) and carry no metadata_only flag, so they passed the predicate
without proving a decrypt. The predicate is now an allowlist on positive
proof
: a record clears the reauth budget only if it carries authenticated
coordinates (latitude and longitude both not None) — the sole output of
the crypto path. This rejects metadata_only sentinels, SEMANTIC rows, and
any future report‑less shape across both consumer paths (poll cycle and
background push), without changing own/foreign coordinate‑report handling, and
keeps manual locate upward‑only. Added a predicate unit matrix (SEMANTIC,
partial‑coordinate, 0.0) and poll/background regression tests; mutation‑checked
(reverting to the denylist and dropping one coordinate axis both fail the new
tests).


Review note (Codex round 8)

Codex flagged that the poll path derived the decrypt proof from the single
display-selected record returned by async_get_device_location. That selector
ranks by newest last_seen, so a newer report-less SEMANTIC/metadata row can
hide a sibling coordinate report that decrypted in the same response, leaving
cycle_had_successful_decrypt false and risking a spurious reauth.

Fix: the decrypt proof is now computed over the full response via a shared
any_real_location_record(records) predicate (single source of truth in
decrypt_locations.py). async_get_device_location carries the full-list
verdict to the poll loop through an internal _decrypt_proven hint (popped
before caching, like _report_hint); the gate prefers the hint and falls back
to the single record only when it is absent. The background push path now uses
the same shared helper instead of an inline any(...), so both automatic paths
share one full-list contract. Manual locate stays upward-only and strips the
hint at the source. New mutation-sharp tests cover the helper, the API hint, and
the poll reset; full suite green (4010 passed).


Review note round 9 (test hygiene, Codex commit 74dd6640): Codex flagged asyncio.run() in the newly added test_api_location_selection.py tests, which tests/AGENTS.md forbids (HA manages the event loop; asyncio_mode = auto). Converted all four asyncio.run() call sites in that file to async def + await (the two new decrypt-proof tests plus two pre-existing same-file tests) and removed the file from LEGACY_ALLOWLIST in test_guard_asyncio_run_antipattern.py, so the guard now enforces the rule there. test_sync_wrappers_guard_when_loop_running keeps its semantics (it now runs inside the managed loop, exactly its guarded condition). The deliberate asyncio.new_event_loop() loop-capture test is intentionally left as-is. No production code changed; guard + affected suites green, ruff + mypy --strict clean.

…ecrypt budget

The shared consecutive-decrypt-failure budget (which gates re-authentication)
was cleared whenever a decode produced a non-empty result. The decode layer,
however, returns a single metadata_only sentinel row when no encrypted report
decrypts but the secrets bundle still yields key material (e.g. a phone without
a fresh fix). That row is non-empty but carries no authenticated decrypt, so:

- background push (fcm_receiver_ha): _note_decrypt_success_for_entry fired on it
- poll cycle (polling): cycle_had_successful_decrypt was set on it

Report-less metadata_only pushes/polls interleaved with real failures could keep
a stale E2EE key below the reauth threshold, leaving the account stuck with no
location updates and no reauth prompt.

Introduce a shared predicate is_real_location_record() in decrypt_locations.py
(the sentinel's producer, the single source of truth for the metadata_only
contract) and gate both success signals on it. A genuine decrypted record still
clears the budget; the metadata still flows downstream. Manual locate stays
upward-only (unchanged).

Addresses the Codex review on PR #182.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 72335b8ef2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +896 to +898
if not record:
return False
return record.get("metadata_only") is not True

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not count semantic-only rows as decrypt success

When the only decoded rows are SEMANTIC, no encrypted location was authenticated: the decryptor appends those rows with decrypted_location=b"" and continues before the crypto path, and the payload has metadata_only absent. This predicate therefore returns true for semantic-only pushes/polls, so those report-less updates can still clear the shared decrypt-failure reauth budget between real DecryptionError cycles and leave a stale E2EE key below the threshold; require evidence of an encrypted/decrypted coordinate report or explicitly exclude semantic-only rows before returning true.

Useful? React with 👍 / 👎.

…budget

SEMANTIC rows skip the crypto path (decrypted_location=b"", continue before
authentication) and carry no metadata_only flag, so the previous
`metadata_only is not True` denylist wrongly treated them as proof that the
account-wide shared key still decrypts. A semantic-only push/poll interleaved
with real DecryptionError cycles could clear the reauth budget and strand a
stale E2EE key below the threshold.

Replace the denylist with an allowlist on positive proof: a record clears the
budget only if it carries authenticated coordinates (latitude and longitude
not None), the sole output of the crypto path. This rejects metadata_only
sentinels, SEMANTIC rows, and any future report-less shape across both consumer
paths (poll cycle + background push) without changing own/foreign
coordinate-report handling. manual locate stays upward-only.

Tests: predicate unit matrix extended (SEMANTIC, partial-coordinate, 0.0),
plus poll-cycle and background-push regression tests proving a SEMANTIC-only
result does not reset the counter. Mutation-checked (denylist revert and
single-axis predicate both turn the new tests red).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@jleinenbach

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 742824aa7d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

# SEMANTIC row (no decrypted coordinates) -- prove nothing, so
# one report-less device must not mask another device's stale
# key for the whole cycle.
if is_real_location_record(location):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve successful decrypts hidden by semantic records

When a locate response contains both a decrypted coordinate report and a newer SEMANTIC/metadata row, api.async_get_device_location() has already selected just one record before this polling code sees it, and the selector prioritizes the newest last_seen. Checking only that selected location here can leave cycle_had_successful_decrypt false even though the same response did decrypt coordinates successfully, so the shared decrypt-failure counter is not reset and a later intermittent failure can still trigger reauth. Please carry the “any real coordinate record” proof from the full record list (or an explicit flag) instead of testing only the selected row.

Useful? React with 👍 / 👎.

…ted record

The poll path cleared the shared decrypt-failure (reauth) budget only when the
single display-selected location record was an authenticated coordinate report.
api.async_get_device_location collapses a device's response to one record via
_select_best_location, which ranks by newest last_seen. A newer report-less
SEMANTIC/metadata row can therefore outrank and hide a sibling coordinate
report that decrypted successfully in the same response, so the poll loop missed
the proof, left the counter intact, and a later transient failure could trip a
spurious reauth.

The decrypt proof is a property of the whole response, not of one record. Add a
shared any_real_location_record(records) predicate in decrypt_locations.py and
evaluate it over the full candidate list. async_get_device_location carries the
full-list verdict to the poll loop via an internal _decrypt_proven hint (popped
before caching, like _report_hint); the gate prefers the hint and falls back to
the single record only when it is absent (legacy/mocks). The background push
path now uses the same shared helper instead of an inline any(...), keeping both
automatic paths on one full-list contract. Manual locate stays upward-only and
strips the hint at the source.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@jleinenbach

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 74dd6640aa

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread tests/test_api_location_selection.py Outdated
api = GoogleFindMyAPI(cache=_StubCache())
return await api.async_get_device_location("dev-1", "Tracker")

best = asyncio.run(_run())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use pytest-asyncio instead of asyncio.run

tests/AGENTS.md says tests under tests/ must never call asyncio.run() because Home Assistant's pytest-asyncio harness already manages the event loop; this newly added test starts its own loop (and the next added test repeats the pattern), which can clash with async fixtures or session cleanup when the module runs under the HA harness. Please mark the test async and await the helper directly instead.

Useful? React with 👍 / 👎.

… allowlist

tests/AGENTS.md forbids asyncio.run() inside tests because Home Assistant's
pytest-asyncio harness already manages the event loop (asyncio_mode = auto);
a competing loop breaks fixtures. Codex flagged the two location-selection
tests added for the decrypt-proof fix.

Convert all four asyncio.run() call sites in tests/test_api_location_selection.py
to async def + direct await (the two newly added decrypt-proof tests plus two
pre-existing same-file tests). test_sync_wrappers_guard_when_loop_running now
runs in the managed loop, which is exactly its guarded condition, so its
assertions are unchanged. The file is now free of asyncio.run(), so remove it
from LEGACY_ALLOWLIST in tests/test_guard_asyncio_run_antipattern.py; the guard
now enforces the no-asyncio.run rule on this file going forward.

The remaining ad-hoc loop in test_sync_wrappers_use_provided_session_loop uses
asyncio.new_event_loop()/run_until_complete to capture the session-bound loop
and is intentionally left as-is (the AST guard only matches asyncio.run).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@jleinenbach

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🎉

Reviewed commit: 468a69089a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@jleinenbach
jleinenbach merged commit 1387f85 into 1.7.2 Jun 18, 2026
19 checks passed
@jleinenbach
jleinenbach deleted the fix/codex-182-metadata-only-decrypt-proof branch July 3, 2026 17:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant