Skip to content

fix(unifi): reuse recycled NFC cards (free on deactivate, reclaim from disabled holder)#33

Merged
RyanMorash merged 6 commits into
mainfrom
fix/card-reuse-bind-conflict
Jun 29, 2026
Merged

fix(unifi): reuse recycled NFC cards (free on deactivate, reclaim from disabled holder)#33
RyanMorash merged 6 commits into
mainfrom
fix/card-reuse-bind-conflict

Conversation

@RyanMorash

@RyanMorash RyanMorash commented Jun 29, 2026

Copy link
Copy Markdown
Member

Problem

reconcile crashed/alerted every cycle with:

UnifiClientError: 1 user update(s) failed this cycle: contact=7590: CODE_CREDS_NFC_HAS_BIND_USER: This NFC card is already assigned to another user.

A physical NFC card that CiviCRM assigns to a member was still bound in UniFi Access to a different user, and door-sync binds with force_add=false. The card got stuck because deactivation never removed it, so a recycled card could never be reassigned. CiviCRM being clean didn't help — the only duplicate-card safety guard checks CiviCRM members, not UniFi state.

Key UniFi constraint (found in production): UniFi rejects NFC-card mutations on a deactivated user (HTTP 404 "no-man zone"). A card cannot be unbound from a disabled account — this shapes both fixes below.

Changes

So a recycled card never strands a member:

  1. Identify the holder on conflict — when a bind is rejected, the per-user error and log identify who holds the card: contact=NNNN (STATUS) for a sync-managed user, otherwise the UniFi user id + status. The member's name is never logged (PII stays out of logs/alerts, matching the rest of door-sync); the contact id resolves to the member in CiviCRM. Card number stays redacted (last-4, architecture §11).
  2. Free the card on deactivation — a departing user's card(s) are deleted before status is set to DEACTIVATED (while the account is still active and the delete is permitted), best-effort so a cleanup failure never blocks cutting access.
  3. Reclaim from a disabled holder — if a bind still conflicts and the current holder is disabled, door-sync force-reassigns the card to the new member (force_add: true). The card can't be unbound from a deactivated user first, so forcing the new bind is the only way to move it. An active holder (or one not in the fetch snapshot) is left untouched and identified in the error. After a reclaim, the moved token is dropped from the holder's cached card list so a later same-cycle op on that contact doesn't DELETE a token it no longer owns.

force_add is false for every normal bind — door-sync never displaces an active user. force_add: true is used only to reclaim from a confirmed-disabled holder. An unknown/null holder status is treated as not disabled, so a card is never force-reclaimed from a holder whose state can't be confirmed.

Internals: a token → holder map (id + status only) captured for every user during fetch_users(); the three card-delete loops and both bind sites unified into _delete_cards_for_contact() / _bind_nfc_card() / _put_bind_card().

Docs (docs/architecture.md §7, design spec) updated to match.

Testing

  • uv run pytest353 passed (added: deactivate-frees-card-before-status, identify-holder-by-id, reclaim-via-force_add, null-status-not-reclaimed, plus the updated ordering test)
  • uv run pyrefly check → 0 errors · ruff check / ruff format --check → clean

Review

Addressed CodeQL (py/clear-text-logging-sensitive-data — dropped the holder name) and Copilot (null-safe holder fields; cache coherence after reclaim; description/behavior alignment).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • NFC card handling now supports automatic card reclamation when a card is held by a disabled account.
    • Deactivating a user now clears their NFC card assignment first, helping cards become reusable sooner.
  • Bug Fixes

    • Bind conflicts now return clearer, more actionable error messages with the current card holder’s status.
    • Card rebinds no longer fail unnecessarily when the existing holder is already disabled.
  • Documentation

    • Updated design notes to reflect the latest NFC card lifecycle behavior.
  • Tests

    • Added coverage for deactivation, conflict handling, and reclaim behavior.

RyanMorash and others added 2 commits June 29, 2026 17:56
Two related fixes for CODE_CREDS_NFC_HAS_BIND_USER (a card CiviCRM assigns
to a member is still bound to a different UniFi user, so the force_add=false
bind is rejected and the cycle alerts every run):

- Deactivation now also deletes the user's NFC card(s), freeing the number so
  a recycled physical card can be reassigned. Status is set first, so cutting
  access never depends on the card cleanup succeeding.

- When a bind is still rejected because the card is bound elsewhere (a member
  deactivated before this behavior shipped, or a manually-enrolled admin card),
  the per-user error now names the current holder — contact id for a
  sync-managed user, else the UniFi user id, plus display name and status — so
  the alert is actionable. Card number stays redacted (architecture §11).

A token->holder map is captured for every user (sync-managed or not) during
fetch_users(). The three card-delete loops and both bind sites are unified into
_delete_cards_for_contact() and _bind_nfc_card(). force_add stays false.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the card-reuse story for CODE_CREDS_NFC_HAS_BIND_USER. When a bind is
rejected because the card is still bound elsewhere, door-sync inspects the
current holder (from the fetch_users snapshot):

- Disabled holder: the card is dead state on a deactivated user (e.g. a member
  deactivated before cards were freed on deactivation). door-sync unbinds it
  from them and retries the bind, logging a redacted warning. This auto-recovers
  the backlog of recycled cards that the deactivate-frees-card change can't
  reach (already-inactive users are never re-processed by the reconciler).
- Active holder (or one not in the snapshot): left untouched; the bind fails as
  a per-user error naming the holder, so an operator resolves it deliberately.

force_add stays false — door-sync never force-steals; it only unbinds a card a
disabled holder is no longer using. Card numbers stay redacted (architecture
§11). The raw bind PUT is factored into _put_bind_card().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 29, 2026 21:57
@coderabbitai

coderabbitai Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@RyanMorash, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 48 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 71c2c768-538f-4a0d-a6e8-753fbeba27df

📥 Commits

Reviewing files that changed from the base of the PR and between be9c069 and 4851b91.

📒 Files selected for processing (6)
  • CLAUDE.md
  • docs/architecture/conventions.rst
  • docs/superpowers/specs/2026-05-22-unifi-client-design.md
  • docs/superpowers/specs/2026-05-27-manual-test-plan.md
  • src/door_sync/unifi/client.py
  • tests/test_unifi_client.py
📝 Walkthrough

Walkthrough

UnifiClient gains NFC card-holder conflict handling: fetch_users() now records which UniFi user holds each token in a new _holder_by_token cache. Deactivation pre-deletes a user's NFC cards (best-effort) before sending the status update. Bind conflicts reclaim cards from disabled holders via force_add=True; active-holder conflicts raise descriptive, redacted errors. Architecture and spec docs are updated to match, and new tests cover all scenarios.

Changes

NFC Card Lifecycle and Bind-Conflict Handling

Layer / File(s) Summary
_CardHolder dataclass, constants, and holder cache
src/door_sync/unifi/client.py
Adds CODE_CREDS_NFC_HAS_BIND_USER error constant, _CardHolder dataclass (user_id, employee_number, status), and _holder_by_token per-instance cache used throughout conflict resolution.
Record card holders during fetch_users
src/door_sync/unifi/client.py
fetch_users() calls new _record_card_holders(row) per paginated user row, building token → _CardHolder snapshots for conflict lookup.
Card bind helpers and conflict reclaim logic
src/door_sync/unifi/client.py
Refactors bind flow into _delete_cards_for_contact (with keep_token), raw bind request, and _bind_nfc_card which handles CODE_CREDS_NFC_HAS_BIND_USER by reclaiming with force_add=True only for disabled holders; adds _holder_is_disabled and _describe_card_holder helpers.
Deactivation NFC card pre-deletion
src/door_sync/unifi/client.py
_apply_deactivate best-effort deletes NFC cards before the DEACTIVATED status PUT; failure is logged and does not block deactivation.
Tests for deactivation cleanup and bind-conflict scenarios
tests/test_unifi_client.py
Adds tests for: NFC card DELETE before deactivation PUT; bind conflict against active holder (raises with redacted details, no DELETE); reclaim from disabled holder (force_add retry, cache update); null-status holder treated as unknown (no reclaim). Updates existing sequencing test to include the NFC card delete step.
Architecture and spec documentation
docs/architecture.md, docs/superpowers/specs/2026-05-22-unifi-client-design.md
Architecture doc adds reconciliation-cycle subsection for NFC card reuse/bind conflicts. Spec updates deactivation step ordering and force_add allowed-use exception.

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant _bind_nfc_card
  participant UniFiAPI
  participant _holder_by_token

  Caller->>_bind_nfc_card: bind token for contact
  _bind_nfc_card->>UniFiAPI: PUT /users/:id (force_add=False)
  UniFiAPI-->>_bind_nfc_card: 409 CODE_CREDS_NFC_HAS_BIND_USER
  _bind_nfc_card->>_holder_by_token: lookup holder by token
  alt holder status is DEACTIVATED
    _bind_nfc_card->>UniFiAPI: PUT /users/:id (force_add=True)
    UniFiAPI-->>_bind_nfc_card: 200 OK
    _bind_nfc_card->>_holder_by_token: drop token from disabled holder cache
    _bind_nfc_card-->>Caller: success
  else holder is active or unknown
    _bind_nfc_card-->>Caller: raise error with redacted holder description
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested labels

codex, aardvark

🐇 A card once lost, now tracked by token's name,
Disabled holders yield — the sync reclaims!
Best-effort delete before the DEACTIVATE call,
force_add for the fallen, redacted for all.
Hop hop, the cache updates, no PII in sight —
The bunny keeps NFC clean through the night! 🃏

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: NFC card reuse, including freeing cards on deactivation and reclaiming them from disabled holders.
Docstring Coverage ✅ Passed Docstring coverage is 86.67% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/card-reuse-bind-conflict

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-code-quality

github-code-quality Bot commented Jun 29, 2026

Copy link
Copy Markdown

Code Coverage Overview

Languages: Python

Python / code-coverage/pytest

The overall coverage remains at 93%, unchanged from the branch.


Updated June 29, 2026 22:46 UTC
Code Coverage is in Public Preview. Learn more and provide us with your feedback.

Comment thread src/door_sync/unifi/client.py Fixed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR updates the UniFi client reconciliation flow to prevent recycled NFC cards from getting “stuck” on prior UniFi users, improving operational recovery and making bind-conflict alerts actionable.

Changes:

  • On deactivation, delete the user’s cached NFC card bindings after setting status=DEACTIVATED.
  • On bind conflict (CODE_CREDS_NFC_HAS_BIND_USER), enrich the error with the current holder, and automatically reclaim the card only when the holder is disabled.
  • Add/adjust tests and update design/architecture documentation to reflect the new behavior and redaction guarantees.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
src/door_sync/unifi/client.py Adds card-holder tracking, frees cards on deactivation, and implements disabled-holder reclaim + improved conflict messaging.
tests/test_unifi_client.py Adds coverage for deactivation freeing cards, active-holder conflict naming, and disabled-holder reclaim retry.
docs/superpowers/specs/2026-05-22-unifi-client-design.md Updates the step-by-step apply semantics and force-add rationale to match the new reclaim behavior.
docs/architecture.md Documents card reuse handling (proactive free + reactive reclaim) and holder-identifying bind conflict alerts.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/door_sync/unifi/client.py Outdated
Comment thread src/door_sync/unifi/client.py Outdated
Comment thread src/door_sync/unifi/client.py Outdated
The reclaim path hit HTTP 404 "CODE_NOT_FOUND / no-man zone" in production:
UniFi rejects NFC-card mutations on a *deactivated* user, so the card cannot be
DELETEd off a disabled holder. Two corrections, same root cause:

- Reclaim: instead of DELETE-then-rebind (impossible on a disabled holder),
  force-reassign the card to the new member with force_add=true, which moves it.
  Still gated on the holder being confirmed-disabled; active/admin holders are
  never displaced.
- Deactivate: free the card *before* setting DEACTIVATED (while the account is
  still active and the delete is permitted), best-effort so a cleanup failure
  never blocks cutting access. Any leftover is reclaimed on next assignment.

force_add stays false for every normal bind; true is used only to reclaim from a
disabled holder. Tests + docs (architecture §7, design spec) updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@RyanMorash

Copy link
Copy Markdown
Member Author

Pushed a follow-up correction (a5ca7df) after the reclaim path fired in production:

WARNING ... card ****1957 is bound to disabled contact=7518 ('James Comegys', DEACTIVATED); reclaiming it
DELETE .../users/6d2d99f5.../nfc_cards/delete -> HTTP 404 CODE_NOT_FOUND "you entered no-man zone"

Root cause: UniFi rejects NFC-card mutations on a deactivated user, so a card can't be DELETEd off a disabled holder. That broke the reclaim's delete-then-rebind, and the same constraint would have broken the deactivate path's delete-after-status-change.

Corrections (same root cause):

  • Reclaim: force-reassign with force_add: true (moves the card) instead of DELETE-then-rebind. Still gated on the holder being confirmed-disabled.
  • Deactivate: free the card before setting DEACTIVATED (while the account is still active), best-effort so it never blocks cutting access.

352 tests pass, pyrefly 0 errors, ruff clean.

Comment thread src/door_sync/unifi/client.py Fixed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comment thread src/door_sync/unifi/client.py
Comment thread src/door_sync/unifi/client.py
…che coherence

Review feedback on PR #33 (CodeQL + Copilot):

- CodeQL py/clear-text-logging-sensitive-data: _describe_card_holder logged the
  holder's display_name (PII), which the codebase otherwise never logs (it uses
  contact ids). Drop the name from _CardHolder entirely; identify holders by
  contact id (sync-managed) or UniFi user id, plus status.
- Null-safety in _record_card_holders: use `or ""` for id/status/token. A JSON
  null status previously stringified to "None", which _holder_is_disabled read
  as *disabled* — i.e. door-sync could force-reclaim a card from a holder whose
  state was actually unknown. Now an unknown status is never treated as disabled.
- Cache coherence: after force-reassigning a card off a disabled holder, also
  drop the moved token from that holder's cached card list (_forget_holder_card),
  so a later same-cycle op on the holder doesn't DELETE a token it no longer owns.

Tests updated/added: holder identified by id not name; null-status holder is not
reclaimed; reclaim prunes the holder's cached card. Docs updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread src/door_sync/unifi/client.py Dismissed
…ntion

Round out the documentation for the card-reuse work:

- conventions.rst: add the "no member names in logs/alerts" convention alongside
  card-ID redaction (identify by contact_id; show-diff CLI may still print names
  as direct operator output).
- unifi-client-design spec: note the bind-conflict handling in the apply-flow
  step; align the force_add note to "identify by id, not name".
- manual-test-plan: 4e now verifies the card is freed on deactivation; new 4f
  covers recycled-card reassignment, reclaim from a disabled holder (the
  production case), and the active-holder guard.
- CLAUDE.md: add the no-member-names hard rule next to card-ID redaction.

Docs only; no code change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (3)
docs/architecture.md (2)

255-261: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add reference to cache coherence update after reclaim.

The architecture describes the reclaim flow well but omits that the implementation also updates internal caches (_holder_by_token.pop(token, None) and _forget_holder_card) after a successful force-reclaim. This prevents later operations in the same cycle from attempting to delete a token the disabled holder no longer owns. Consider adding a brief note like: "After reclaim, the card is removed from the holder cache so subsequent same-cycle operations see correct state."

This documents an important implementation detail that prevents subtle bugs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/architecture.md` around lines 255 - 261, The reclaim flow description is
missing the internal cache update that happens after a successful force-reclaim.
Update the architecture note around the bind conflict handling to mention that,
after `force_add: true` succeeds, the implementation removes the token from the
holder cache via `_holder_by_token.pop(token, None)` and `_forget_holder_card`
so later same-cycle operations see the corrected ownership state. Keep the note
near the existing `force_add`/reclaim explanation and reference those symbols
for easy location.

255-261: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Clarify whose name is protected in the PII note.

The text states "The member's name is never logged" but the context describes logging the current holder's identifiers (contact id or user id, plus status). The member being provisioned is identified by contact_id in the error, while the holder's name is what's actually kept out of logs. Consider rephrasing to: "The holder's name is never logged" or "PII (names) are never logged; only identifiers are disclosed" for clarity.

This is a documentation clarity issue, not a functional bug.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/architecture.md` around lines 255 - 261, Clarify the PII note in the
card reuse section so it matches the behavior described by the bind-conflict
flow in this docs block. Update the wording around the logging rule in the “On
bind conflict” paragraph to state that the current holder’s name is never logged
(or that only identifiers/status are logged), since the error message exposes
the holder’s contact id or UniFi user id plus status while keeping names out of
logs; use the surrounding “force_add”, “current holder”, and “contact id”
wording as the anchor for the edit.
docs/superpowers/specs/2026-05-22-unifi-client-design.md (1)

152-152: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Generalize the reclaim reference beyond "step 4".

The note says "any card left behind is reclaimed on its next assignment (step 4)" but reclaim via _bind_nfc_card can occur in both step 4 (Update credential) and step 6 (Add, including reactivate). Consider rephrasing to "on its next bind attempt" or "when the card is next assigned to any member" to avoid implying reclaim only happens during credential updates.

This improves accuracy since the reclaim path is shared across multiple steps.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/superpowers/specs/2026-05-22-unifi-client-design.md` at line 152, Update
the deactivate note in the spec so the reclaim wording is not tied only to “step
4”; the current reference under the deactivate flow is too specific because
`_bind_nfc_card` can reclaim a leftover card during both the Update credential
path and the Add/reactivate path. Rephrase the sentence near
`diff.to_deactivate` to say the card is reclaimed on its next bind attempt or
next assignment to any member, keeping the delete-before-status-change behavior
unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/door_sync/unifi/client.py`:
- Around line 774-782: The delete-failure path in UnifiClient’s contact
deactivation flow leaves _holder_by_token with a stale ACTIVE snapshot after
_delete_cards_for_contact() raises, which can cause later bind conflicts to
mis-handle the holder state. Update the exception handling in the
deactivate/cleanup logic around _delete_cards_for_contact() to drop or refresh
the affected token entries in _holder_by_token to DEACTIVATED so the cache
matches the warning and future binds can force-reclaim correctly.

---

Nitpick comments:
In `@docs/architecture.md`:
- Around line 255-261: The reclaim flow description is missing the internal
cache update that happens after a successful force-reclaim. Update the
architecture note around the bind conflict handling to mention that, after
`force_add: true` succeeds, the implementation removes the token from the holder
cache via `_holder_by_token.pop(token, None)` and `_forget_holder_card` so later
same-cycle operations see the corrected ownership state. Keep the note near the
existing `force_add`/reclaim explanation and reference those symbols for easy
location.
- Around line 255-261: Clarify the PII note in the card reuse section so it
matches the behavior described by the bind-conflict flow in this docs block.
Update the wording around the logging rule in the “On bind conflict” paragraph
to state that the current holder’s name is never logged (or that only
identifiers/status are logged), since the error message exposes the holder’s
contact id or UniFi user id plus status while keeping names out of logs; use the
surrounding “force_add”, “current holder”, and “contact id” wording as the
anchor for the edit.

In `@docs/superpowers/specs/2026-05-22-unifi-client-design.md`:
- Line 152: Update the deactivate note in the spec so the reclaim wording is not
tied only to “step 4”; the current reference under the deactivate flow is too
specific because `_bind_nfc_card` can reclaim a leftover card during both the
Update credential path and the Add/reactivate path. Rephrase the sentence near
`diff.to_deactivate` to say the card is reclaimed on its next bind attempt or
next assignment to any member, keeping the delete-before-status-change behavior
unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b9bf619f-58b6-4a85-8d69-b59f34645e03

📥 Commits

Reviewing files that changed from the base of the PR and between f01d322 and be9c069.

📒 Files selected for processing (4)
  • docs/architecture.md
  • docs/superpowers/specs/2026-05-22-unifi-client-design.md
  • src/door_sync/unifi/client.py
  • tests/test_unifi_client.py

Comment on lines +774 to +782
try:
self._delete_cards_for_contact(user_id, unifi_user.contact_id)
except UnifiClientError as card_exc:
logger.warning(
"contact %d: could not free card before deactivation (%s); "
"it will be reclaimed when the number is next assigned",
unifi_user.contact_id,
card_exc,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm _holder_by_token is never updated to reflect a same-cycle deactivation.
rg -nP '_holder_by_token' src/door_sync/unifi/client.py

Repository: TechValleyCenterOfGravity/door-sync

Length of output: 411


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant sections around holder caching and deactivation flow.
sed -n '420,720p' src/door_sync/unifi/client.py
printf '\n---\n'
sed -n '748,820p' src/door_sync/unifi/client.py

Repository: TechValleyCenterOfGravity/door-sync

Length of output: 17275


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the helper logic that gates reclaimability and any cache mutation helpers.
sed -n '1,220p' src/door_sync/unifi/client.py
printf '\n---\n'
sed -n '220,420p' src/door_sync/unifi/client.py
printf '\n---\n'
sed -n '820,940p' src/door_sync/unifi/client.py

Repository: TechValleyCenterOfGravity/door-sync

Length of output: 22966


Keep the holder cache in sync on delete failure. If _delete_cards_for_contact() fails here, the warning says the card will be reclaimed on the next assignment, but _holder_by_token still has the pre-fetch ACTIVE snapshot. A later bind conflict in the same cycle will treat the holder as active and re-raise instead of force-reclaiming. Dropping or refreshing the affected token(s) to DEACTIVATED here would match the cache to the warning.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/door_sync/unifi/client.py` around lines 774 - 782, The delete-failure
path in UnifiClient’s contact deactivation flow leaves _holder_by_token with a
stale ACTIVE snapshot after _delete_cards_for_contact() raises, which can cause
later bind conflicts to mis-handle the holder state. Update the exception
handling in the deactivate/cleanup logic around _delete_cards_for_contact() to
drop or refresh the affected token entries in _holder_by_token to DEACTIVATED so
the cache matches the warning and future binds can force-reclaim correctly.

If the pre-deactivation card delete fails, the card stays bound to the user we
then deactivate, but _holder_by_token still carries the ACTIVE snapshot taken at
fetch time. A same-cycle reassignment of that number would read ACTIVE and
refuse to reclaim — a spurious per-user failure. After the status change
succeeds, refresh the contact's cached card holders to DEACTIVATED (user_id
guarded), so the still-bound card is force-reclaimed correctly.

Test: deactivation with a failing card delete, then a same-cycle reassignment of
that card is reclaimed (force_add).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@RyanMorash RyanMorash merged commit acac68a into main Jun 29, 2026
6 checks passed
@RyanMorash RyanMorash deleted the fix/card-reuse-bind-conflict branch June 29, 2026 22:48
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.

3 participants