fix(telemetry): show the cardholder name for card-opened doors (#46) #93
No reviewers
Labels
No labels
blocked
bug
enhancement
high-priority
low-priority
needs-info
needs-triage
ready-for-agent
ready-for-human
referenced
research
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
gabogg/hikcentral!93
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/telemetry-open-longest-cardholder"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #46
Problem
In Doors Opened the Longest, a card-opened door showed
TARJETA: <id>with no name, while Live View showed the cardholder's name.Root cause, confirmed on production (read-only, 2026-09-24):
OnEventNotify,event_acs) carrydata.cardNo,data.personIdanddata.personCode, but nopersonName. This is from the production gateway log, for example{'cardNo': '4267114340', 'personId': '65', 'personCode': '25108016', …}.door_access_cyclesin production holds about 4,900 card-without-namecycle-*rows, 5 of them open at the time of the check.event-hc-*, 8,112 of them) come from thedoor/eventspoll, which does returnpersonName. Live View shows those rows, so it has the name.The design comment on #46 placed
personIdon thedoor/eventspoll. In fact it is the webhook that carries the id without the name, so this PR captures it there (and from the poll too, where present).Approach
door_access_cyclesgets a newperson_idcolumn. It is created in the table definition and added in place on existing databases, together with an index. The webhook handler and bothdoor/eventspolls readpersonIdand pass it throughrecord_access_sessioninto the aggregator.cardNoandpersonIdonto an already-open cycle that lacks them. Before, it did so only when the event also had a name. This covers the common order: the state poll opens an anonymous cycle, then the card webhook arrives.app/services/person_directory.py(PersonDirectory):cached_name(personId)returns the name,''when HikCentral has none, orNoneif it hasn't been asked yet.request(personId, on_resolved)starts at most one background lookup per person throughPOST /artemis/api/resource/v1/person/personId/personInfowith{"personId": …}. I checked the response shape live:data.data.personName.get_door_overview()is synchronous and runs on every poll, so it never waits on HikCentral. On a cache miss it shows the card only for that poll and schedules the lookup.cycle_repo.fill_person_name_asyncsets the name on every nameless cycle of that person, so the ranking and the live activity stream agree. The door service then broadcasts a fresh overview.The frontend is unchanged: it already renders
PERSONA:wheneverpersonNameis set, through i18n in both languages.Verification
New
tests/test_cardholder_name_resolution.py(7 tests), with HikCentral stubbed and a real SQLite database:personIdandcardNoon the open cycle;CREDENTIAL; 6 overviews make one HikCentral request;BUTTON;person_idcolumn wheninit_dbruns.Full suite: 289 passed, 1 skipped.
ruff checkandruff formatare clean, and pre-commit passed.Notes for review
PersonDirectorycalls HikCentral itself, rather than going through a repository. It sits inservices/like the other callers ofartemis.person_idis HikCentral's internal id. The sanitizer maskscard_noin exported copies but notperson_id. Masking it too would be a one-line change if you want it.person_id, so the fix applies to openings after the deploy.🤖 Generated with Claude Code
Checklist
Review follow-up
Background name lookup tasks are retained until complete, and exported database copies clear
person_id. The person-info response used here provides a name; a card-only lookup does not invent a role. The remaining localizedsummaryLabelbehavior is recorded on #73 for the language-agnostic payload work.Code review — pass 1 (two-axis)
Reviewed the PR's own diff (
git diff <base>...<head>); Standards and Spec ran as independent passes and are reported separately, not reranked. Severity: P1 must fix · P2 fix before merge · P3 minor. Per review policy, pass 1 findings are all to be fixed on the branch.Standards
Layering and migration are fine. All SQL stays in
app/db/:fill_person_name_asyncis a repository method, and the migration only reads columns, callsALTERand creates the index ininit_schema, matching how the file already handlesdoor_recordsandoccupancy_config.ADD COLUMN ... TEXT DEFAULT ''plusCREATE INDEX IF NOT EXISTSis re-runnable and non-blocking in SQLite. The Artemis call is mocked, and the migration test runs against a real on-disk database. No hard breaches of the documented standards.Bugs
app/services/person_directory.py:57:loop.create_task(self._resolve_and_report(...))keeps no reference to the task. The event loop holds only weak references to tasks (asyncio docs), so an untracked task can be garbage-collected before it finishes. Then thefinallynever runs and the person id stays in_in_flightfor good — that person is never looked up again. Keep tasks in a set and remove withadd_done_callback(set.discard).person_directory.py:81:res.get("success")runs before theisinstance(res, dict)guard. Non-dictreswould raiseAttributeErrorinstead ofPersonLookupError.artemis.request_asyncalways returns a dict, so it's guard order, not a live bug. Check the type first or drop the guards.Judgement calls (baseline smells)
person_id = str(cycle.get("personId") or cycle.get("person_id") or "")in both insert paths (cycle_repository.py:101,:160), andperson_id = str(ev.get("personId") or "")in bothdoor/eventspolls (door_service.py:292,:372). Extends pre-existing duplication.cardNo,personId,personName,personRoleandpicUrltravel as loose params/dict keys. A smallCredentialvalue would absorb the next field. Related to code-standards §2.3 ("avoid passing untyped, raw dictionaries…"), but the cycle dicts pre-exist, so not a new breach.access_cycle_aggregator.py:87now copiescardNowhenever missing, making the olderupdated["cardNo"] = ...at:93in thehas_personbranch mostly redundant.PersonDirectory._names(:33) caches every name forever; a renamed person keeps the old name until restart. Acceptable at mall scale, but say so in the docstring.Other notes
sanitizer_repository.py:45and:97maskcard_noin exported copies but not the newperson_id. The PR raises this already; it's a one-line change and keeps credentials treated consistently.fill_person_name_asyncfillsperson_namebut notperson_role. Poll-sourced Live View rows carry a role, so card-resolved rows will show a name without a role.Spec
The PR does what the settled design comment on #46 asked for, with a small, justified deviation. No P1 or P2 issues.
Deviation from the design comment: decided and sound
The PR captures
personIdfrom the webhook (app/services/door_service.py:822) and from bothdoor/eventspolls (:292,:372). Production evidence: the webhook carriespersonId/personCodewith nopersonName, and poll rows are already named. So the nameless row really comes from the webhook, and the change follows the spec's intent ("capture the key") where the key is actually missing. "Validate the exact field name" is done:personId, response shape checked live (data.data.personName).Settled points
cardNo"app/db/database.py:160), aggregator merge (app/services/access_cycle_aggregator.py:84-88)person/personId/personInfo, cached onto the cycle"app/services/person_directory.py, write-back inapp/db/cycle_repository.py:203tests/test_cardholder_name_resolution.py:161-164):202)(a) Missing or partial
cardNo/personIdonto the cycle (access_cycle_aggregator.py:84-87). No forced-open test; add one asserting FORCED still wins.(b) Scope creep
app/services/person_directory.py:14: the 5-minute retry after a failed lookup wasn't asked for. Reasonable way to meet "no per-poll request" during an outage — keep it._nameshas no size limit (grows with distinct cardholders).app/services/door_service.py:1404-1408) is needed so the panel updates without waiting for the next state change. Fine.(c) Implemented, but looks wrong or untested
tests/test_cardholder_name_resolution.py:220-236: the "poll first, card webhook second" test checkscardNo/personIdbut not thatopen_triggermoves BUTTON → CREDENTIAL, nor that the name resolves in that order. Code handles it via theelif open_triggerbranch; assert it.app/services/access_cycle_aggregator.py:39-41: a card-only cycle keepssummaryLabel"Salida: Botón de Apertura / Sensor" after its name resolves, and the panel readssummaryLabel(command_deck_adapter.js:838). PR defers to #73 — record that in #73 so it isn't lost.door/eventswith a name but nopersonIdare unaffected (correct). Pre-deploy cycles stay nameless, as the PR notes.Tests cover the claims: capture, no waiting, one request per person, write-back reaching recent activity, retry after failure. (Suite not run; review was read-only.)
Summary: Standards 9 (1×P2, 8×P3) — worst: untracked
create_taskinPersonDirectorycan be GC'd and permanently wedge_in_flight. Spec 4 (all P3) — worst: no FORCED-trigger test now that alarm events copy card/person ids.🤖 Generated with Claude Code
Code review — pass 2 (two-axis)
Re-reviewed at head
5ac58b0. Each axis verified every pass-1 finding against the code (not the commit messages), then reviewed the fix commits for new problems. Verdicts: ✅ fixed · ◐ partial · ❌ not addressed · ↺ regressed · ⊘ declined with documented reason. Per review policy, from pass 2 on P1/P2 block merge; P3s go to one follow-up issue for this PR.Standards
Checked
90d3841and5ac58b0at head5ac58b0. In a throwaway worktree,tests/test_cardholder_name_resolution.py+tests/test_database_sync.pypass (24),ruff checkclean. Migration unchanged since pass 1 and still safe: checks columns beforeALTER ... ADD COLUMN person_id TEXT DEFAULT '', usesCREATE INDEX IF NOT EXISTS(app/db/database.py:160-166).Pass-1 findings
create_task90d3841,person_directory.py:37,59-61: tasks held inself._tasks, removed viaadd_done_callback(self._tasks.discard);_in_flightstill cleared infinally(:71-72).fetch_name_async90d3841,person_directory.py:85-92:isinstance(res, dict)first.person_idextraction (cycle_repository.py:101,160;door_service.py:292,372)cardNoassignment inhas_personbranch90d3841removes it (access_cycle_aggregator.py:86-95). Side benefit: a named event with no card no longer wipes an existingcardNo.person_directory.py:29docstring: cached until restart.person_idnot masked in exportssanitizer_repository.py:52-57,111-116setsperson_id = ''in sync and async paths.fill_person_name_asyncdoesn't fillperson_role## Checklistwith four ticked items.New findings
app/db/sanitizer_repository.py:52-57,111-116:person_idmasking has no test. code-standards §4.3: "Every new feature, endpoint, or service fix must have accompanying tests." The checklist ticks "Sanitized database copies clear person ids", but nothing checks it. The existingtests/test_database_sync.py:95seedsdoor_access_cycleswithout aperson_idcolumn, so the newUPDATEsilently hitsOperationalError(logged at debug) and the test still passes. A silent regression would leak ids into exported copies. Addperson_idto the seeded table and assert''after sanitizing.app/db/cycle_repository.py:203-214:fill_person_name_asynchas no guard for emptyperson_id. Today the only caller filters''(person_directory.py:49,door_service.py:1396). A future call with''would write one name onto every nameless cycle with no person id (~4,900 prod rows). Return 0 when empty.sanitizer_repository.pyrepeats the try/UPDATE/debug block in sync and async paths. Existing file pattern; note only.Nothing else at P1/P2.
Merge readiness (this axis): One P2 left — the missing sanitizer test for
person_id(a few lines). P3s → follow-up issue.Spec
Reviewed
origin/master...origin/fix/telemetry-open-longest-cardholderat head5ac58b0, mainly fix commits90d3841and5ac58b0against #46's settled design comment. Throwaway worktree:tests/test_cardholder_name_resolution.py8 passed; full suite 290 passed, 1 skipped.Pass-1 findings
cardNo/personId; test FORCED wins5ac58b0addstest_forced_open_with_card_keeps_forced_trigger(tests/test_cardholder_name_resolution.py:243-252), assertingcardNo,personId,open_trigger == "FORCED". But the door starts closed, so the event takes the create path (access_cycle_aggregator.py:51). The copy lines the finding pointed at (:84-87,elsebranch, existing cycle) remain unexercised. Reading the code, FORCED still wins there (:101-104sets FORCED whenpersonNameis blank), but no test pins it._namescache90d3841docstring: "Resolved names remain cached until process restart" (person_directory.py:29). No size limit, accepted in pass 1.5ac58b0assertsopen_trigger == "CREDENTIAL"and, aftersettle(),personName == "NOMBRE VERIFICADO"(:238-241; stub seeded at:221).summaryLabelstays "Salida: Botón de Apertura / Sensor"; record on #73summaryLabelafter async person-name resolution … derive that label from the final trigger/name in the active UI language."New findings
tests/test_cardholder_name_resolution.py:243-252(same gap as the partial row). Untested: an existing open cycle (open_door(..., already_open=True)+ anonymous opening) followed by an alarm webhook carrying a card; and thatopen_longest_item(...)["open_trigger"]staysFORCEDaftersettle()resolves the name. Spec: "Button / manual / forced / unknown triggers unaffected."resolve_open_triggerwithcard_no). Wording only; a forced cycle stays FORCED. No PR action.90d3841also clearsperson_idin both sanitizer paths (sanitizer_repository.py:52-57,:111-116), guarded likecard_no. Task-retention and type-guard fixes add no spec drift.Whole-diff re-check: every settled-spec bullet still met ("carried onto cycle rows alongside
cardNo", "resolves a blank name viaperson/personId/personInfo, cached onto the cycle", "No per-poll HikCentral request", no local-list match / 300 s window, "Frontend unchanged"). No P1/P2.Merge readiness (this axis): Ready. Remaining P3 (FORCED on an already-open cycle) → follow-up issue.
Summary: Blocking (P2): Standards —
person_idexport masking untested (existing sanitizer test silently skips it). Spec ready (P3 only). Worst per axis: Standards → that untested masking; Spec → FORCED-on-existing-cycle path still untested (P3).🤖 Generated with Claude Code