fix(occupancy): camera-group schema, orphan guard and migration — phase 1 (#29) #53
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!53
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/counting-events-camera-integrity"
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 #29
Phase 1 of 2. Merges first — #52 and #54 are blocked on this PR. Supersedes #51 (closed as duplicate of #29).
This description replaces the 2026-09-22 draft following the second grilling session (2026-09-22/23). Changes versus that draft: a second fallback and a webhook bug are now in scope; the schema shrinks (no group-level events, no nullable-column rebuild, no
needs_confirmation); orphan rows are quarantined, not re-attributed.Context the design rests on
zone_namealready carries the group name,occupancy_service.py:423). The even split divides by 1, so per-camera figures are exact. The system now requires this — seeCONTEXT.md§3 Camera Group and ADR 0005 (both on #54). The full group-join cutover is deferred to #62.models.py:111-114,CONTEXT.md§2), not passages.Invariant this PR enforces
Three writers currently break it:
1. Sync fallback A —
occupancy_service.py:1830-18312. Sync fallback B —
occupancy_service.py:1880-1888A group present in
count_listbut absent fromgroup_listfalls back to the group code viagroup_cam_map.get(g_code, {...})andinfo.get("in_cams") or [(g_code, ...)].Both write rows under a group code. Those rows never match
counting_cameras, so the reconciliation baseline reads0and the full cumulative Artemis count is re-injected on every 3-second poll (#29).3. Webhook misread —
webhook_controller.py:31-56The controller treats 131585/131586 as "Entering/Exiting" and 131588 as a "realtime counting report". They are
ALARM_FORCED_OPEN,ALARM_TIMEOUTandRESTORED. Every forced-open or timeout door alarm received by webhook writes a fake people-counting passage under a door code. Those rows are invisible to the KPIs but are read byget_hourly_flow_distribution_async(trust rules R1/R2, #35) and appear in the operator flux stream. Introduced ince0ff5d(2026-09-03).Scope
Guard
count_listbut notgroup_list, is skipped entirely: flag set on itscounting_camera_groupsrow, one warning — not a growing table. Nothing attributable is lost: there is no camera to attribute to.webhook_controller.py. Door handling andSUBSCRIBED_EVENT_TYPESstay (doors need those codes).ENTRADA/SALIDA/ACCESOmatching) does not occur with this facility's camera names (all 14 register asBIDIRECTIONAL), and is removed wholesale by #62. No per-direction handling.1:1 invariant guard
counting_camera_groups, warned in the log, and surfaced to operators (health / config indicator).Schema (additive only)
counting_camera_groups:resource_group_code(PK),resource_group_name,member_count, empty-group flag, multi-camera flag, timestamps. Persisted from Artemis on each sync.CREATE TABLE IF NOT EXISTS+ in-place migration, followingdatabase.py:271-276.counting_cameras.resource_group_code— needed to compute group size, and by #62 later.people_counting_eventsplusquarantined_atandreason).people_counting_events.resource_group_code, nullablecamera_index_code(SQLite table rebuild),needs_confirmation.Orphan migration
Orphan rows are not re-attributable: each runaway row holds the whole cumulative Artemis count at one poll, so summing them is meaningless.
people_counting_eventsrow whosecamera_index_codematches nocounting_camerasrow (group codes, door codes, anything else) into the quarantine table, with areason(group_code/door_code/unknown).Integrity check
Startup and periodic:
Log loudly when non-empty.
Out of scope
Group-level events and the group join (#62) · #35 dataset parity and ADR 0005 (#54) · the WebSocket batching (#52).
Acceptance criteria
:1830-1831,:1880-1888) removed.webhook_controller.pyno longer writes counting events; door webhook handling unchanged.group_listemit zero events, set their flag, and log one warning.GET /api/occupancy/camera-groups(no UI indicator yet — see implementation notes).counting_camera_groups,counting_cameras.resource_group_codeand the quarantine table created viaCREATE TABLEand in-place migration; existing databases upgrade without loss.relatedResourceInfoList→ zero events + one warning; group incount_listbut notgroup_list→ zero events; webhook 131585/131586 payload → zero counting events; two-camera group → counted, flagged; migration quarantines a group-code row and a door-code row.node --test).🤖 Generated with Claude Code
WIP: fix(occupancy): counting-events ↔ counting_cameras integrity (#28, #29, #35)to WIP: fix(occupancy): camera-group schema, orphan guard and migration — phase 1 (#29)Production validation checklist
To run read-only against the production DB once WireGuard/SSH access is in place. Results decide nothing already settled, but confirm the assumptions this PR and ADR 0005 rest on.
Please paste results here. If (1) returns rows, stop and revisit ADR 0005 / #62 before implementing.
Implementation notes (
ba57b9e)Implemented. Verification: pytest 223 passed / 1 skipped (10 new in
tests/test_counting_integrity.py),node --test59/59,ruff check+ruff format --checkclean. Migration run twice against a copy of the localdata/hikcentral.db: 3,726 rows / 35,081 passages preserved, new columns and tables present, integrity check found nothing to quarantine.Where the code differs from the description, and why:
len(in_cams)fails. Sending that direction to the members is correct under 1:1 groups (the group's only camera measured it), loses no data, and never writes a group code.0and gets the whole cumulative count re-injected every poll. The #29 body mentions this case; startup syncs cameras first, so it only affects the ≤60 s window before a new camera is registered.counting_camerasis empty, because an empty registry would make every row look orphaned.GET /api/occupancy/camera-groups(operator auth) returnsis_emptyandis_multi_cameraper group, plus a one-time log warning. No dashboard badge; that is frontend work, left for a follow-up if you want it.Still open: the production validation checklist above.
Production validation — results (2026-09-23)
Run read-only (
sqlite3URImode=ro) on the production server against the live DB. The dataset holds 595,589 counting events / 1,030,753 passages, 2026-09-03 → 2026-09-23.door_eventsshows noALARM_FORCED_OPEN/ALARM_TIMEOUTever received, and oneRESTORED(131588), whose missingenterNum/exitNumwrote nothing.Conclusion: no historical data needs correcting. Everything in this PR is preventive, and the design assumptions (1:1 groups, no per-crossing counting path) are confirmed against production.
WIP: fix(occupancy): camera-group schema, orphan guard and migration — phase 1 (#29)to fix(occupancy): camera-group schema, orphan guard and migration — phase 1 (#29)Code review
Reviewed commit
ba57b9e6f715d9356257ef4fd1e34324e1e94ceaagainst base72fdcdd26dc2d99ae13644da349522b8a609cf5busing independent Standards and Spec review passes.Standards
get_camera_groupslacks its return annotation; new test fixtures and helpers also omit annotations. Code standards §2.2 requires explicit parameter and return types. Ruff's configured rules do not enforce this requirement.Spec
P1 — The registered-camera invariant remains bypassable. The specification requires “Every counting event's
camera_index_codeis a registered camera,” but the new guard protects polling only. I reproducedPOST /api/occupancy/eventreturning 200 and persisting seven passages for an unregistered camera. Those events can reach telemetry and hourly aggregates before quarantine. Enforce registration at the shared write boundary and cover the direct endpoint.P2 — Missing groups receive no persisted flag. Acceptance requires groups absent from
group_listto “set their flag,” but this branch only warns and skips. I reproduced agrp-ghostcount response producing no group record. Consequently, the designated operator endpoint cannot reveal the skipped group. Persist its unmapped/empty state before continuing.Verification
223 tests passed, 1 skipped, with three
aiosqlitethread warnings. Ruff passed. Both specification findings were reproduced with isolated SQLite and mocked hardware.Standards: 1 finding, worst P3 (annotations). Spec: 2 findings, worst P1 (invariant bypass).
Review addressed —
f4e38a8P1: the invariant could be bypassed. Fixed at the shared write boundary.
record_counting_event_asyncnow looks up the camera and raisesUnregisteredCameraErrorwhen there is nocounting_camerasrow. The guard sits on the one method every writer goes through (sync, simulator,POST /api/occupancy/event), not only on polling. The endpoint maps the error to 422CAMERA_NOT_REGISTERED. New tests cover it at the service level and through the direct endpoint: an unregistered camera returns 422 and writes no row; a registered one returns 200.test_occupancy_api_endpoints_rbac: it posted an event for a camera it never registered, so it now registers the camera first. That test relied on the behaviour this PR forbids.P2: missing groups had no persisted flag. Fixed.
counting_camera_groupsgainsis_unlisted. When HikCentral returns counts for a group it does not list, the sync writes that flag (once per state change, not every poll) before skipping the group, soGET /api/occupancy/camera-groupsshows it. A later camera sync that lists the group clears the flag and records its real name. The quarantine reasongroup_codecovers these codes too. Test extended.ALTER TABLEfor the new column: the table itself is introduced by this unmerged PR.P3: annotations. Fixed. Added the return type on
get_camera_groups,-> NoneonUnregisteredCameraError.__init__, and full parameter/return annotations on the test fixture, helpers and test functions (pytest.MonkeyPatch,pytest.LogCaptureFixture).Verification: pytest 225 passed / 1 skipped,
node --test59/59,ruff check+ruff format --checkclean, pre-commit hooks passed.Out of scope, tracked in #64:
POST /api/occupancy/eventhas no authentication dependency. Anyone who can reach the server can inject passages for any registered camera. P1 limits the damage to registered cameras; it does not close the hole. #64 also covers the unvalidated payload (negativecount, thePASSdirection, backdated timestamps) and recommends deleting the endpoint, since nothing calls it.Code review — round 2
Reviewed
f4e38a8againstmaster(72fdcdd) with two independent passes: Standards (docs/standards/code-standards.md,AGENTS.md,CONTEXT.md, plus a Fowler code-smell baseline) and Spec (this PR's description, #29, and the deviations declared in the comments above). The Spec pass ran the suite in a throwaway worktree: 225 passed, 1 skipped.Standards
Layering, the error response shape, logging and the testing approach all follow the repo standards.
Documented standards
app/controllers/webhook_controller.py:18-22: this PR edits the signature ofreceive_event_webhook, but it still has no return type. It is the only annotation gap left.dict[str, Any](occupancy_service.py:429-445,occupancy_repository.py:634), althoughCameraGroupItemexists. The same keys always travel together, which also makes them a data clump.occupancy_controller.py:364:status_code=422is a bare literal; the §3.1 example usesstatus.HTTP_…constants.Code smells (judgement calls)
occupancy_repository.py:665and:701: the twoINSERT … ON CONFLICTupserts oncounting_camera_groupshave the same shape, and the int-to-bool row mapping is written twice.occupancy_service.py:1955-1957:_skipped_group_warnings, a set named for warnings, also decides whether to write to the database.(c_code, c_name)tuples; quarantine reasons are bare strings in both the SQL and the summary.is_empty,is_unlisted,is_multi_camera) are not defined inCONTEXT.md.database.py:282-285: the new migration usesexcept Exception: pass(copying the file's existing pattern);sqlite3.OperationalErrorwould be narrower.Spec
Previous round: P1 (invariant bypass) fixed: the sync, the simulator and
POST /api/occupancy/eventall write through the guardedrecord_counting_event_async. P2 (unlisted groups) fixed only in the straightforward case, see below.Implemented but wrong
group_listemit zero events, set their flag, and log one warning."mark_group_unlisted_asyncruns only when the group code is not already in the warn-once set, but groups skipped for other reasons (empty, unregistered member) land in the same set. A group first skipped as empty and later dropped from the list never getsis_unlisted, and gets no new warning. Reproduced:is_unlisted: False, only the "no member cameras" warning logged.group_codeby looking incounting_camera_groups. If the startup camera sync fails on an upgraded DB, that table is empty and legacy group-code rows are labelledunknown. Only the label is affected; production has 0 orphans.Missing or partial
main.py:42) shares atrywithdoor_manager.sync_doors_async(). If the door sync raises, the check is skipped, and the monitor schedules its first run an hour later (monitor_service.py:93).Scope creep: none beyond the deviations declared in the implementation notes; the code matches each one.
Standards: 8 findings, worst P2 (raw dicts between service and repository). Spec: 3 findings, worst P2 (the unlisted flag shares the warn-once set). All of them are being addressed in a follow-up commit.
Review round 2 addressed —
f899891All 11 findings from the round-2 review above are fixed.
Spec
GroupSkipReason:UNLISTED,EMPTY,UNREGISTERED_MEMBER) and acts only when the reason changes: it warns once per new reason, and persistsis_unlistedwhen a group entersUNLISTED. A group first skipped as empty and later dropped from HikCentral's list now gets the flag and a second warning, and keeps its known name. Regression test:test_group_turning_unlisted_after_another_skip_is_flagged, which covers the scenario the reviewer reproduced.Nonebefore that. A legacy group-code row can no longer be labelledunknown. Test:test_quarantine_skipped_until_camera_groups_registered.tryblocks inmain.py, so a failed door sync no longer skips the counting integrity check. Test:test_startup_integrity_check_runs_when_door_sync_fails(runs the real lifespan with the door sync raising).Standards
CameraGroupRegistrationin,CameraGroupRegistrationResult(group+previous) out, andget_camera_groups_asyncreturnslist[CameraGroupItem], which the endpoint returns directly.counting_camera_groupsnow has one write path (_save_camera_group) and one row mapper (_row_to_camera_group);mark_group_unlisted_asyncreads the row, sets the flag and saves it through the same path.GroupSkipReasonchange above; the set is gone.QuarantineReasonenum (values bound as SQL parameters, not literals) withQuarantineTallycounts; group members are aGroupMemberNamedTuple.-> dict[str, str]). The repository helpers also takeaiosqlite.Connection/aiosqlite.Rowinstead ofAny.status.HTTP_422_UNPROCESSABLE_CONTENT; the_ENTITYname is deprecated in the installed Starlette.sqlite3.OperationalError). The file's pre-existingexcept Exceptionblocks were left alone.CONTEXT.md§3 gains Camera Group States (empty / unlisted / multi-camera) and Quarantined Counting Event. They are added at the end of §3, away from the lines #54 edits, to avoid a merge conflict. The pre-commit end-of-file hook also removed two trailing blank lines from the file.Verification
pytest 228 passed / 1 skipped (3 new tests),
node --test59/59,ruff check+ruff format --checkclean, pre-commit hooks passed.Code review — round 3 (pre-merge)
Reviewed
f899891againstmaster(72fdcdd) with two independent passes: Standards (docs/standards/code-standards.md,AGENTS.md,CONTEXT.md, plus a Fowler code-smell baseline) and Spec (this PR's description, #29, and the amendments and review responses above).Pre-flight: mergeable into
masterwith no conflicts · CIlint-and-testgreen onf899891· pytest 228 passed / 1 skipped andnode --test59/59, re-run by the Spec pass in a throwaway worktree.Verdict: not ready to merge yet. Standards is clear. Spec has one merge blocker, a regression introduced by the round-2 fix.
Standards
All round 1–2 fixes hold. No merge blockers, and no documented rule broken outright.
get_camera_asyncreturnsdict[str, Any] | None(occupancy_repository.py:631, used atoccupancy_service.py:616) althoughCountingCameraItemexists.cam_payloadalso gains an untypedresource_group_codekey (occupancy_service.py:477)._group_skip_reasonscontrols whethermark_group_unlisted_asyncruns (occupancy_service.py:574-578). Camera sync clears the DB flag without touching the dict, so the two can drift.group_cam_map[g_code] = {"name", "in_cams", "out_cams"}(:1933) is still a raw dict (the shape predates this PR; the PR edits it).GroupMember/GroupSkipReasonlive in the service whileQuarantineReasonlives inapp/schemas/;GroupSkipReasonvalues are log sentences.QuarantineTally.totalmeans passages;passageswould say so, and the class has no docstring.quarantine_orphan_counting_events_asyncreturnsNone(skipped),{}(nothing moved) or a summary. It is documented, but easy to misread.HTTP_422_UNPROCESSABLE_CONTENTwhilemain.py:83/100still uses_ENTITY. Production runs Starlette 1.6.0 (checked read-only), which has_CONTENT, so there is no deploy risk.Spec
All three round-2 fixes verified by behaviour. Nothing from the acceptance criteria is missing; no scope creep.
Implemented but wrong
Nonewhenevercounting_camera_groupsis empty (occupancy_repository.py:768). A deployment whose cameras came from the fallback camera list (used whenresourceGroupListis unavailable) never registers a group, so the check is permanently off, logs only at INFO (occupancy_service.py:594), and orphans stay put. Reproduced with a legacygrp-legacyrow, 1 camera, 0 groups. Production has groups and is not affected today._skip_group_asyncrecords the reason in memory (occupancy_service.py:576) beforemark_group_unlisted_asyncwrites. If that write fails once, later polls see the same reason and never retry, so the group never appears inGET /camera-groups.count_listaltogether staysis_unlisted=Trueforever, and its stale_group_skip_reasonsentry suppresses the warning if it returns with the same reason.Note (P3, not reproduced): if
enforce_counting_integrity_asyncraises at startup (main.py:47),sync_initial_countsandreconcile_and_quarantine_historical_anomalies_asyncare skipped because they share itstry.Standards: 7 findings, worst P2 (
get_camera_asyncraw dict). Spec: 3 findings + 1 note, worst P2 (integrity check permanently off without camera groups, merge blocker).Plan: fix the Spec P2 in this PR before merge; move every other finding to a follow-up issue.
Review round 3 addressed —
039ae91Merge blocker (Spec P2) fixed. The integrity check no longer switches itself off when no camera group is registered. It now returns a typed
CountingIntegrityReportinstead ofNone/{}/ a summary:SKIPPED_NO_CAMERASDETECTED_ONLYCHECKEDA deployment on the fallback now gets a loud error every hour instead of silence. Once groups register, the next run quarantines the same rows with the correct reason, so round 2's mislabelling fix still holds. This also resolves the Standards P3 about the return value having three meanings.
New tests:
test_orphans_detected_and_logged_when_no_camera_groups(the scenario the reviewer reproduced) andtest_orphans_quarantined_with_correct_reason_once_groups_register.Everything else (Standards P2 on
get_camera_async, the remaining Standards P3s, and the three Spec P3s) is non-blocking and moved to #65.Verification: pytest 229 passed / 1 skipped,
node --test59/59, ruff clean, pre-commit hooks passed.