fix(telemetry): Doors Opened the Longest shows card ID instead of cardholder name for card-opened doors #46

Closed
opened 2026-09-21 19:14:19 +00:00 by gabogg · 1 comment
Owner

📌 Problem Statement

In the Tactical Operations Deck (DUAL_OPS_DECK), a door opened by a card credential shows the card ID instead of the cardholder name in the Doors Opened the Longest ranking panel (#tactical-open-longest-panel), rendering the tier-2 credential line as TARJETA: 1049283 with no PERSONA:.

Confirmed on production: the Live View activity stream shows the cardholder name correctly for the same events, while Doors Opened the Longest shows only the card ID. So the name is present in the data — the ranking panel is losing it, not the ingestion.

This is a follow-up to #24 (which introduced the tier-2 opening-method / credential display).

🔍 Root Cause Analysis (preliminary)

The two panels resolve person identity from different sources, and only one of them carries the name:

  • Live View — CommandDeckAdapter.renderLiveActivityStream renders snapshot.recentActivity, which the backend fills from cycle_repo.get_recent_sync(50) (app/services/door_service.py:1259 area). That list includes the named access/card event, so the name shows.

  • Doors Opened the Longest — CommandDeckAdapter.renderOpenLongest renders snapshot.openLongest, whose personName is resolved in get_door_overview from the single active-open cycle:

    active_cycle = cycle_repo.get_active_for_door_sync(code) if is_open else None
    person_name = active_cycle.get("personName", "") if active_cycle else d.get("personName", "")
    ...
    card_no = active_cycle.get("cardNo", "") if active_cycle else d.get("cardNo", "")
    

    app/services/door_service.py:1133-1141

    get_active_for_door_sync returns the most recent cycle with is_open = 1 for that door (app/db/cycle_repository.py:58-70). That active-open cycle carries the card number but an empty person_name, so the ranking item ends up with cardNo and no personName. The frontend then correctly renders card-only (command_deck_adapter.js:864-878 shows PERSONA: only when personName is non-empty).

In other words: the cardholder name and the active-open state live in two different cycle rows for the same door. Live View sees the whole list and shows the named one; the ranking keys off the active-open row alone, which is the one without the name.

Note: an earlier theory (that upstream sends no name at all) is falsified by Live View showing names. The name is in the system; the ranking's single-cycle lookup just doesn't pick it up.

🧭 Likely Direction (deferred — needs its own design pass)

Not scoping the fix here. Candidate approaches, to be evaluated in the follow-up:

  1. Enrich at read time: in get_door_overview, when the active-open cycle has cardNo but blank personName, backfill the name from the correlated card event for the same door (match by door_index_code within a short time window).
  2. Merge at ingestion: when the card-swipe / access-grant event and the door-open event are stitched into a cycle, carry personName onto the is_open = 1 row so a single lookup already has it.
  3. Change the source: have openLongest resolve identity the same way Live View does, from the recent-cycle list, rather than the lone active cycle.

Option 2 is the most robust (fixes the data, not the view), but touches the aggregator (access_cycle_aggregator.py) and event ingestion; option 1 is the smallest change. The right call depends on whether the card and open events are guaranteed to correlate cleanly — that's the design question the follow-up needs to answer.

Reproduction

  1. On production, open a door via card credential.
  2. Leave it open long enough to appear in Doors Opened the Longest.
  3. Observe: Live View shows the cardholder name; the ranking panel shows TARJETA: <id> with no name.

Scope note

The frontend needs no change — it renders card-only precisely because personName arrives blank. The fix is entirely backend identity resolution for the open-longest ranking.

✅ Acceptance Criteria

  • A door opened by card credential shows the cardholder name (PERSONA: <name>) in #tactical-open-longest-panel, alongside or in place of the card ID, consistent with what Live View shows for the same door.
  • The name resolution does not regress button / manual / forced / unknown trigger rendering.
  • The active-open cycle and the recent-activity list agree on the cardholder for a given open door.
  • Backend test covering: card-opened door whose active-open cycle lacks a name resolves the name from the correlated card event, and the resulting open_longest item carries personName.
  • Verified across Spanish and English localizations.
## 📌 Problem Statement In the Tactical Operations Deck (`DUAL_OPS_DECK`), a door opened by a card credential shows the **card ID instead of the cardholder name** in the **Doors Opened the Longest** ranking panel (`#tactical-open-longest-panel`), rendering the tier-2 credential line as `TARJETA: 1049283` with no `PERSONA:`. Confirmed on production: the **Live View** activity stream shows the cardholder **name** correctly for the same events, while **Doors Opened the Longest** shows only the card ID. So the name is present in the data — the ranking panel is losing it, not the ingestion. This is a follow-up to #24 (which introduced the tier-2 opening-method / credential display). ## 🔍 Root Cause Analysis (preliminary) The two panels resolve person identity from **different sources**, and only one of them carries the name: - **Live View** — `CommandDeckAdapter.renderLiveActivityStream` renders `snapshot.recentActivity`, which the backend fills from `cycle_repo.get_recent_sync(50)` (`app/services/door_service.py:1259` area). That list includes the named access/card event, so the name shows. - **Doors Opened the Longest** — `CommandDeckAdapter.renderOpenLongest` renders `snapshot.openLongest`, whose `personName` is resolved in `get_door_overview` from the **single active-open cycle**: ```python active_cycle = cycle_repo.get_active_for_door_sync(code) if is_open else None person_name = active_cycle.get("personName", "") if active_cycle else d.get("personName", "") ... card_no = active_cycle.get("cardNo", "") if active_cycle else d.get("cardNo", "") ``` `app/services/door_service.py:1133-1141` `get_active_for_door_sync` returns the most recent cycle with `is_open = 1` for that door (`app/db/cycle_repository.py:58-70`). That active-open cycle carries the **card number but an empty `person_name`**, so the ranking item ends up with `cardNo` and no `personName`. The frontend then correctly renders card-only (`command_deck_adapter.js:864-878` shows `PERSONA:` only when `personName` is non-empty). In other words: the cardholder name and the active-open state live in **two different cycle rows** for the same door. Live View sees the whole list and shows the named one; the ranking keys off the active-open row alone, which is the one without the name. > Note: an earlier theory (that upstream sends no name at all) is **falsified** by Live View showing names. The name is in the system; the ranking's single-cycle lookup just doesn't pick it up. ## 🧭 Likely Direction (deferred — needs its own design pass) Not scoping the fix here. Candidate approaches, to be evaluated in the follow-up: 1. **Enrich at read time**: in `get_door_overview`, when the active-open cycle has `cardNo` but blank `personName`, backfill the name from the correlated card event for the same door (match by `door_index_code` within a short time window). 2. **Merge at ingestion**: when the card-swipe / access-grant event and the door-open event are stitched into a cycle, carry `personName` onto the `is_open = 1` row so a single lookup already has it. 3. **Change the source**: have `openLongest` resolve identity the same way Live View does, from the recent-cycle list, rather than the lone active cycle. Option 2 is the most robust (fixes the data, not the view), but touches the aggregator (`access_cycle_aggregator.py`) and event ingestion; option 1 is the smallest change. The right call depends on whether the card and open events are guaranteed to correlate cleanly — that's the design question the follow-up needs to answer. ## Reproduction 1. On production, open a door via card credential. 2. Leave it open long enough to appear in **Doors Opened the Longest**. 3. Observe: Live View shows the cardholder name; the ranking panel shows `TARJETA: <id>` with no name. ## Scope note The frontend needs **no** change — it renders card-only precisely because `personName` arrives blank. The fix is entirely backend identity resolution for the open-longest ranking. ## ✅ Acceptance Criteria - [ ] A door opened by card credential shows the cardholder **name** (`PERSONA: <name>`) in `#tactical-open-longest-panel`, alongside or in place of the card ID, consistent with what Live View shows for the same door. - [ ] The name resolution does not regress button / manual / forced / unknown trigger rendering. - [ ] The active-open cycle and the recent-activity list agree on the cardholder for a given open door. - [ ] Backend test covering: card-opened door whose active-open cycle lacks a name resolves the name from the correlated card event, and the resulting `open_longest` item carries `personName`. - [ ] Verified across Spanish and English localizations.
Author
Owner

✅ Design settled (grilling session)

Approach chosen — resolve from HikCentral by personId, not by correlating local rows. The earlier read-time local-match / door/events re-query fallback is dropped: it built robust correlation logic to work around a key we had simply chosen not to capture. Simpler to capture the key.

Root cause (confirmed)

The named card event and the is_open = 1 cycle are two different rows: the OpenAPI poll writes the card event as a standalone is_open = 0 row via cycle_repo.upsert_sync, bypassing the aggregator's existing name-backfill (access_cycle_aggregator.py:82-88). The active-open cycle keeps cardNo but no personName. We currently parse only personName, cardNo, picUri, readerName from door/events — no person id.

Settled spec

  • Capture personId (or personCode) from acs/v1/door/events during ingestion, and propagate it onto the cycle rows the same way cardNo is propagated. (Implementer validates the exact field name in the live payload — the catalog has both person/personId/personInfo and person/personCode/personInfo resolvers.)
  • Resolve on demand: in get_door_overview, when the active-open cycle has a personId but blank personName, call HikCentral /api/resource/v1/person/personId/personInfo (or personCode/personInfo).
  • Cache: write the resolved name back onto the cycle so it resolves once, not every 1 Hz poll.
  • Drop the local-list match, the 300 s window, and the door/events re-query fallback — all obviated by carrying the id.
  • Frontend unchanged — it already renders PERSONA: whenever personName is non-empty.

Acceptance criteria (supersede the "Likely Direction" section)

  • personId/personCode captured from door/events and carried onto cycle rows alongside cardNo.
  • Open-longest ranking resolves a blank name via person/personId/personInfo, cached onto the cycle.
  • A card-opened door shows PERSONA: <name> in #tactical-open-longest-panel, matching Live View.
  • Button / manual / forced / unknown triggers unaffected.
  • No per-poll HikCentral request — resolution happens once and is cached.
  • Verified across Spanish and English localizations.

Re-tagged ready-for-agent.

## ✅ Design settled (grilling session) **Approach chosen — resolve from HikCentral by `personId`, not by correlating local rows.** The earlier read-time local-match / `door/events` re-query fallback is **dropped**: it built robust correlation logic to work around a key we had simply chosen not to capture. Simpler to capture the key. ### Root cause (confirmed) The named card event and the `is_open = 1` cycle are **two different rows**: the OpenAPI poll writes the card event as a standalone `is_open = 0` row via `cycle_repo.upsert_sync`, bypassing the aggregator's existing name-backfill (`access_cycle_aggregator.py:82-88`). The active-open cycle keeps `cardNo` but no `personName`. We currently parse only `personName`, `cardNo`, `picUri`, `readerName` from `door/events` — **no person id**. ### Settled spec - **Capture `personId`** (or `personCode`) from `acs/v1/door/events` during ingestion, and propagate it onto the cycle rows the **same way `cardNo`** is propagated. (Implementer validates the exact field name in the live payload — the catalog has both `person/personId/personInfo` and `person/personCode/personInfo` resolvers.) - **Resolve on demand**: in `get_door_overview`, when the active-open cycle has a `personId` but blank `personName`, call HikCentral **`/api/resource/v1/person/personId/personInfo`** (or `personCode/personInfo`). - **Cache**: write the resolved name back onto the cycle so it resolves **once**, not every 1 Hz poll. - **Drop** the local-list match, the 300 s window, and the `door/events` re-query fallback — all obviated by carrying the id. - **Frontend unchanged** — it already renders `PERSONA:` whenever `personName` is non-empty. ### Acceptance criteria (supersede the "Likely Direction" section) - [ ] `personId`/`personCode` captured from `door/events` and carried onto cycle rows alongside `cardNo`. - [ ] Open-longest ranking resolves a blank name via `person/personId/personInfo`, cached onto the cycle. - [ ] A card-opened door shows `PERSONA: <name>` in `#tactical-open-longest-panel`, matching Live View. - [ ] Button / manual / forced / unknown triggers unaffected. - [ ] No per-poll HikCentral request — resolution happens once and is cached. - [ ] Verified across Spanish and English localizations. Re-tagged `ready-for-agent`.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
gabogg/hikcentral#46
No description provided.