# Fix systray Claude usage display: identity pinning + polling storm

Status: DONE (2026-08-15)

## Source request
Owner 2026-08-15 ~11:30: usage limits stale until tray opened; zync shows
multideal's data; "make some code review there see that the architecture is ok"
→ findings F1–F4 recorded in 2026-08-15-systray-claude-token-rotation.md →
owner: "the word" (fix approved) → "write the plan, send sonnet subagents to
implement, you code-review, you fix problems, then land/merge".

## Root causes (verified)
- F1: expected account identity read from `<account_home>/.claude.json`
  (`account_uuid_of`) — CLI-owned, was rewritten in zync's home with
  multideal's uuid by a live-auth-inheriting CLI child. Both the credential
  resolver and the attribution guard consume the poisoned value.
  Consumers: `claude_credentials.live_owner_is`,
  `ClaudeHealthClient._reading_owner_matches`,
  `AccountRegistry._preserve_live_claude_grant` (activation hands the live
  grant to whichever account's .claude.json claims the owner uuid — same
  poison could copy multideal's grant into zync's credentials file).
- F2: 60s GLib timer calls `_refresh_all(force=True)`; force bypasses the
  180s freshness window in `scheduler.refresh_all` → 5 accounts × 1/min →
  sustained 429 on the usage endpoint → 5-min backoffs → stale display.
- F3 (partial, in scope): `_write_rate_limits_payload` non-atomic write_text.
- F4: `_reading_owner_matches` fail-open when expected uuid is None.

## Design
Systray-owned write-once identity pin, per account, outside CLAUDE_CONFIG_DIR
(the CLI can never touch it): `<account_home>.parent/claude-account-identity.json`
holding `{"account_uuid": "..."}`.

- `claude_credentials.py` NEW: `pinned_account_uuid(account_home) -> str | None`;
  `pin_account_uuid(account_home, uuid) -> None` — atomic (mkstemp+os.replace,
  same dir), WRITE-ONCE: existing pin with a different uuid is never
  overwritten (first pin wins; conflict is a no-op).
- `live_owner_is`: expected uuid := `pinned_account_uuid(...)`; no pin → False
  (fail-closed: never route an unpinned account to the shared live file).
  `.claude.json` no longer consulted anywhere for expected identity.
- `ClaudeHealthClient.fetch` bootstrap, before resolve: pin is None →
  `identity.identify(own .credentials.json)` (profile GET, spends nothing) →
  on success `pin_account_uuid`. Dead/invalid own token → no pin → account
  polls its own file and reports its own broken state — never another
  account's data.
- `_reading_owner_matches`: expected := pinned uuid; None → False (fail-closed
  F4) → snapshot "usage attribution unverified".
- `AccountRegistry._preserve_live_claude_grant`: match by
  `pinned_account_uuid` (lazy symbol), not `account_uuid_of`; unpinned
  accounts skipped.
- `_write_rate_limits_payload`: mkstemp in target dir + os.replace.
- `indicator.py` `_refresh_all_timer`: `_refresh_all(force=False)` — the 180s
  staleness window governs background polling. Startup call and menu-"show"
  keep force=True (user-initiated).

## Lanes (disjoint files)
- Lane A (sonnet): claude_credentials.py, claude_health_client.py,
  account_registry.py (+ their tests incl. test_claude_credentials.py,
  test_claude_health_live_credentials.py, new pin tests).
- Lane B (sonnet): indicator.py timer force change + test.
- Main session: review both diffs, fix findings, run full systray suite,
  direct-land, install (`git checkout <sha> -- <files>` into main checkout),
  restart systray-ai.service, runtime verify.

## Acceptance criteria
- Unpinned account NEVER resolves to the live ~/.claude file and NEVER
  displays another account's usage (test: poisoned .claude.json + no pin →
  own file + attribution fail-closed).
- First successful own-file identify pins; pin is immutable afterwards (test).
- `_preserve_live_claude_grant` writes only to a pinned matching account (test).
- Timer tick does not force; menu-show does (test).
- `python3 -m pytest modules/systray/tests/ -q` green except the 2 known
  pre-existing failures (test_account_lock malformed-state,
  test_packaging_desktop_entry).
- Installed + restarted. Runtime proof: zync shows "authentication required"
  (not multideal's numbers); multideal/zync2/claude11-alex each pinned with
  their true uuid; 429 backoffs stop accumulating (~15 min observation).
- Runtime cleanup at install: delete zync's poisoned rate-limits-cache.json
  (systray-written multideal data). zync's .claude.json is CLI-owned — left.

## Receipt (current)
- Lanes A+B implemented by sonnet subagents; main-session review APPROVED both
  with review fixes applied by main session:
  - REVIEW FIX (real): `_preserve_live_claude_grant` with fail-closed pin match
    would silently DESTROY a CLI-rotated live grant when no account is pinned
    yet (symlink replaces the only copy; grants are irreplaceable). Fixed:
    unmatched grants rescued to
    `~/.local/state/overdeck/token-rescue/unclaimed-<uuid>.credentials.json`
    (test added, green).
  - Justified benign: pin write-once check not lock-guarded (single poller
    writer; both racers derive the same uuid from the same file); atomic
    rate-limits cache write omits fsync (non-secret regenerable cache).
- Full suite: 1067 passed, 1 skipped, 2 failed — only the 2 known pre-existing
  failures (test_account_lock malformed-state, test_packaging_desktop_entry).
- Landed on main: 6e23f386a (commit 61386930b + merge). Installed into main
  checkout via `git checkout 6e23f386a -- <4 systray files>`; zync's poisoned
  rate-limits-cache.json deleted; systray-ai.service restarted, active.
- Runtime proof (post-restart): pins created with TRUE uuids — multideal
  c26e762a…, zync2 97d350a7…, claude11-alex 4fd6ced2…; zync unpinned (dead
  grant — fail-closed, can never show multideal's data again); claude12
  unpinned (no credentials file at all — correct); zero journal errors.
- 15-min 429-drain observation + burn-in rotation checks (claude11-alex ~17:33,
  zync2 ~18:40) still running at write time.

## Next executable action
None — done. Burn-in cron one-shots report at 17:41 / 18:48.
