feat(occupancy): publish reviewed historical flow without calibrating #171
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!171
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/historical-flow-publication"
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?
Summary
Draft for #164: atomic approval and publication of a reviewed historical month. It covers source precedence (local observations own any observed camera-hour), long-lived local rollups that survive the 180-day event pruning, an independent quality result for hourly aggregates, and an explicit imported-or-mixed-source exclusion from calibration (both the immediate nightly multiplier update and EWMA/variance/sample history). It was split out of #166. Specification: #164 and the historical-flow RFC.
Stacked on
feat/historical-flow-curation(#163 draft). Only a placeholder commit is on this branch; implementation has not started. It follows #163 in issue order.Architectural impact (planned)
people_counting_events).Verification
scripts/check_docs.py, full pytest.Checklist
Issues closed on merge
Closes #164
🤖 Generated with Claude Code
Marked blocked: implementation is blocked by PR #178 (
feat(statistics): holiday and event context for investor analytics), which is the remaining prerequisite needed to build the parent curation drafts in PR #170 (feat(occupancy): curate monthly historical flow drafts/ #163). PR #171 is stacked on PR #170 and provides the atomic approval/publication transaction for those reviewed drafts.Unblocked: #178 merged (
b64e805). Master is now merged up the stack: #170 and #171 are at the new master. #170 staysblocked, since its prerequisite #177 is still in review.Merged #170 (feat/historical-flow-curation @
888572c) into feat/historical-flow-publication. No conflicts encountered. Full test suite (612 tests) passes cleanly.WIP: feat(occupancy): publish reviewed historical flow without calibratingto feat(occupancy): publish reviewed historical flow without calibratingImplementation complete against #164 acceptance criteria and maintainer decisions of 2026-10-02 on #170.
flow_history_approved_*, never enteringpeople_counting_events.All 621 pytest tests, 248 node tests, ruff format/check, and check_docs.py pass cleanly. Ready for pass 1 review.
Code review, pass 1 (
origin/pr/170...5742d63, spec #164 + #163 decisions)Summary: 5 distinct P1s (after removing duplicates), about 12 P2s, and P3s. Pass 1: fix everything. Judged on #171's own diff over #170 (
8c78911).P1s:
occupancy_service.py:2496-2499): thecontinueskips the cursor advance. It is reachable from the analytics POST.get_trusted_calibration_history_asyncthen raises a TypeError. That takes down nightly calibration, live state and analytics. UseOccupancyManager.create_event_async, or a shared conn-level helper.is_exception,exception_name) are wiped from the business-day record, which overrides the ADR 0007 correction policy. Only purely imported days become IMPORTED. Mixed or lived days keep their record, and #178's record stays the source of holiday and exception identity.APPROVEDandAPPROVECHECK values before it merges (this was added to #170's pass-2 fix list). #171 then needs no rebuild migration; drop the widening from #171 once #170 has it.Key P2s: the approved-revision diff is missing (r53 moved it to #171, so it belongs here); approval must require PREPARED_FOR_APPROVAL and re-check status and revision inside
BEGIN IMMEDIATE(addexpected_revisionand return 409); APPROVED drafts must be immutable; local precedence must survive pruning, so readers use the rollups; rollups exclude reconstructed slices and cameras that aren't counted; an empty COMPLETE day is not trusted; quality thresholds read the config dict (cfg.get), with no cap at 6; partly observed hours stay unresolved; the repository holds no domain logic and makes no service callbacks; the publication test usesisolated_repository_db; re-approval replaces stale annotations.Coordination with #228: #228 adds
occupancy_business_day_schedules.original_value. Whichever PR lands second writesoriginal_valueat approval, instead of using a STAMP audit row (the option ADR 0008 rejected).Order: the #170 pass-2 fixes land first. Merge #170 into #171 before fixing here.
Standards axis
Scope:
git diff origin/pr/170...origin/pr/171(db9d606plus placeholdereee9b1e). Line numbers are at5742d63.Probes ran in a git-archive export under
scratchpad/r171/src, using a throwaway test withisolated_repository_db; it has been deleted. Ruff check and format are clean.tests/test_flow_history_publication.pypasses (9 tests in 65 s).P1
P1-1. The reconcile loop never ends once a day has imported flow. CONFIRMED (probe)
app/services/occupancy_service.py:2496-2499. The newcontinueskips the loop's only advance,cursor_cycle_start = cursor_cycle_end + CYCLE_END_EPSILON(about line 2580).Failure: a local event exists before an approved month, and one approved day has no calibration log.
reconcile_historical_calibrations_asyncthen spins forever on the same cycle and hits the DB on every iteration. This path is reached fromPOSTin analytics_controller.py:326 and fromreconcile_and_quarantine_historical_anomalies_async(line 2616).Probe: local event on 2026-07-30, then 2026-08 approved.
asyncio.wait_for(..., 15)timed out.Fix: advance the cursor before
continue, or restructure the loop. Add a regression test.P1-2. Approving a day with a timed event annotation breaks every trusted-calibration-history reader. CONFIRMED (probe)
app/db/flow_history_repository.py:1386-1430. The approval inserts the event with raw SQL:is_all_day=0when both times are set,start_epoch/end_epochNULL, andstart_time="18:00"(the service stores ISO datetimes).occupancy_repository.py:2970-2975(and 3443) then runsactive_business_day(r["start_epoch"], ...)andr["end_epoch"] - 0.001.Failure: a curation day with
event_annotation="Concert", event_start_time="18:00", event_end_time="22:00"is approved. After that,get_trusted_calibration_history_asyncraisesTypeError: unsupported operand type(s) for -: 'NoneType' and 'float'. The affected callers are nightly calibration (occupancy_service.py:2012, 2112), live state (934, 2956) and analytics (analytics_service.py:1699).Root cause: the repository bypasses
OccupancyManager.create_event_async, which computes the epochs and validates them. The same code shape is behind the layering finding P2-1.P1-3. Approving a month marks every day IMPORTED, including locally lived days, which then leave calibration history. CONFIRMED (probe)
app/db/flow_history_repository.py:1298-1331upsertsoccupancy_business_day_schedules.source='IMPORTED'for every day in the month.occupancy_repository.py:3046-3048/3476-3478andhas_imported_or_mixed_flow_async(3279) exclude any day whose record is IMPORTED, whatever the day's flow source.Failure: 2026-08 was lived locally, and an admin approves it to fill a few gaps. All 31 days get
source=IMPORTED. Their existing trusted calibration logs disappear from EWMA/variance/sample-history selection, so the next nightly k changes. The probe showed a day with only local hours (approvedsource='local_events'): its schedule became IMPORTED andhas_imported_or_mixed_flow_asyncreturned True. The upsert also overwrites the current record (WEEKLY/EXCEPTION/MANUAL) of lived days with draft values, outside the correction and next-day-activation policy (ADR 0007).This contradicts #164 ("do not rewrite already-applied calibration"; the exclusion covers only imported or mixed days) and ADR 0008 (only imported days are "marked as imported"; a lived day's record is its own).
P2
P2-1. Layering: the repository holds domain logic, calls back into the service, and writes another aggregate's tables. CONFIRMED
flow_history_repository.py:1103-1118:get_curation_draft_asyncandapprove_curation_draft_asyncimportflow_history_servicefrom inside the repository, a circular, inverted dependency. Only tests use them (test_flow_history_publication.py:310, 354). code-standards §1.1 says repositories only persist.store_approved_month_async(1120-1479) holds domain rules: day status tallies, day-source classification, the open-window computation and the whole quality verdict (1200-1221). It also hand-writes occupancy schedules, schedule audit, events and event audit, duplicating theOccupancyRepository/OccupancyManagerwriters (Shotgun Surgery, Duplicated Code). That duplication is how P1-2 happened.ApprovedDayRecords and the quality verdict. The repository receives typed records and persists them in one transaction, using shared connection-level helpers from the occupancy repository for schedule and event writes.P2-2. Approval atomicity: every check runs outside the write transaction, and the final UPDATE has no guard. PLAUSIBLE (race; the code path is confirmed by reading)
flow_history_service.py:1246-1310reads the draft and all 31 day details, then callsstore_approved_month_async, which only startsBEGIN IMMEDIATEat repo:1136. Inside the transaction nothing re-checks the draft's status orrevision, andUPDATE ... SET status='APPROVED' WHERE month=?(1433-1440) has no status predicate and no rowcount check.Failure: admin A approves while admin B discards, or patches a day back to UNREVIEWED, in between. The publication is built from the stale read, and the DISCARDED draft is resurrected as APPROVED with its curation_days already deleted.
Also,
CurationApproveRequesthas noexpected_revisionfield, although every other #170 mutation takes one.Fix: re-read the draft row under
BEGIN IMMEDIATE, require the expected status and revision, and raiseDraftConflictError(409) on mismatch.P2-3. Approval skips the PREPARED_FOR_APPROVAL lifecycle, and APPROVED drafts stay editable. CONFIRMED
flow_history_service.py:1247rejects only DISCARDED, so a plainDRAFTcan be approved directly (the probe printed "status before approve: DRAFT"; no test calls prepare-approval). The approve gates (1259-1280) duplicate prepare's gates (1149-1219) and have drifted from them. For example, approval does not re-checkcamera_mismatch_reasontext, and it has no reason gate tied tounresolved_camera_hoursorsuggested_status.APPROVED, but the #170 mutation guards (service 831/907/960/1087; repo 605-607/655-657/721-723/804-806) block onlyPREPARED_FOR_APPROVAL. An APPROVED draft therefore accepts PATCH day/schedule/zero-confirm edits and drifts from what was published.status == 'PREPARED_FOR_APPROVAL'(checked in the transaction, see P2-2), rely on prepare's gates, and treat APPROVED as immutable, like PREPARED.P2-4. Migration: the status/action CHECK changes in CREATE TABLE IF NOT EXISTS never reach an existing database. CONFIRMED (probe)
app/db/database.py:866, 933. Probe: initialise the DB with the pr/170 tree, then with pr/171.UPDATE ... status='APPROVED'fails withCHECK constraint failed: status IN ('DRAFT','PREPARED_FOR_APPROVAL','DISCARDED'), and the'APPROVE'audit insert fails the same way.Failure: if #170 merges and deploys before #171 (they are separate PRs), every approval on that DB returns 500 permanently.
Fix: move the
APPROVED/APPROVEvalues into #170 before it merges, or add a table-rebuild migration (#228 introduces a generic CHECK-rebuild helper).P2-5. Quality thresholds ignore admin configuration; an empty COMPLETE day is trusted. CONFIRMED (probe for the second part)
flow_history_service.py:1282-1286:getattr(cfg, "trust_ratio_min", 0.80)etc. run on the dict returned byget_config_async, so they always return the hard-coded defaults. occupancy_service.py:2085-2165 correctly usescfg.get(...). The samegetattr(cfg, "holiday_open_time")bug exists in #170 (service ~993); report it to #170.flow_history_repository.py:1206: whentotal_vol == 0, every check is skipped andis_trusted=1. Probe: a day with no data, all hoursNONE, marked COMPLETE with a reason, was publishedis_trusted=Truewith 0 counts. #164 says "Admin coverage approval does not itself assert sensor trust".:1220:min(6, trust_min_active_hours)silently caps the configured minimum at 6 (a magic number).P2-6. Re-approval destroys the prior published snapshot. CONFIRMED (reading)
flow_history_repository.py:1139-1144deletes the previousflow_history_approved_days/_hours, and the months row is upserted in place. #164 requires that re-approval "replaces the published revision atomically while retaining prior snapshots". The test at test_flow_history_publication.py:531-543 checks only the staging rows. This overlaps the spec axis.P2-7. Partly observed hours lose their unresolved state in publication, and open-window counts disagree with day totals. CONFIRMED (reading)
database.py:975-987givesflow_history_approved_hoursno unresolved flag. The repository (1262-1268) writes partly observed LOCAL hours assource='local_events'with their partial counts, which cannot be told apart from wholly observed hours. #164: "Leave partially observed hours unresolved". The cam02 case in the precedence test is seeded but never asserted.:1191-1198:open_window_entersumsenter_numover all hours, including unresolved partly observed ones, whileenter_totalexcludes them. Soopen_window_entercan exceedenter_total. The open-window computation also truncates minutes (open/close"09:30"/"21:30"count by hour only), returns 0 when the close time is past midnight, and parses the hour by slicinghour_start[11:13](Primitive Obsession).P2-8. Local rollups count data that published KPIs exclude. CONFIRMED (reading)
occupancy_repository.py:3206-3226sums everypeople_counting_eventsrow, including reconstructed gap slices (raw_payload.reconstructed) and excluded or inactive cameras. The KPI and trust queries filter both out (_COUNTED_CAMERA_JOIN/FILTER, 3355-3358).Failure: year-over-year comparisons built on rollups older than 180 days are inflated relative to live statistics for the same period.
Related, P3-level, PLAUSIBLE: the business day is attributed with the current
daily_reset_timeinstead of the effective-dated reset (ADR 0007).P2-9. The new test module bypasses
isolated_repository_db. CONFIRMED (reading)tests/test_flow_history_publication.py:43-67uses an autouse fixture that DELETEs 20 tables in the session-wide DB, includingoccupancy_calibration_logs,occupancy_eventsandoccupancy_business_day_schedules, which other modules rely on. Its siblingtest_flow_history_curation.py:40usespytestmark = usefixtures("isolated_repository_db"). #233 and1885b27on master were exactly this class of order dependence.P3
CurationApproveRequest(schemas/flow_history.py:427-428) lacks the strip validator that every sibling request has. The probe stored reason' x '.draft.status == "DISCARDED"(service:1247) can never be true, becauseget_curation_draft_row_asyncreturns None for DISCARDED, which yields a 404.FlowHistoryRepository.has_imported_or_mixed_flow_async(1591-1616) duplicates the occupancy one and is unused. The occupancy copy (3279-3304) hard-codes'hikcentral_artemis_hourly'instead ofHISTORICAL_FLOW_SOURCE.FlowHistoryService.get_approved_day_async(1318) has no route.reason or "Historical month approval"fallbacks (1361, 1377, 1426) can never fire.day_details: list[Any](repo:1124) should belist[CurationDayDetail].get_curation_draft_async(repo:1103) has no return annotation.trust_*parameters plusnowtravel together into a 12-argumentstore_approved_month_async. Bundle the thresholds into one type, or read them inside the service-side quality function.events_rolled_up(occupancy_repository.py:3317) counts camera-hour groups, not events (the test expects 1 for 2 events).(start_date, name)(repo:1386-1391) means changed event times on re-approval are silently ignored.get_curation_day_detail_asynconce per day (service:1289-1296), and each call re-queries the whole month. #170 batched these queries for the month view.GET /months/{month}/approvedhas no test, although code-standards §4 requires one for every endpoint.fetch_idis unused at line 197.reason; it should also check that the day and hour rows are intact. It also goes through the test-only repository seam rather than the route.mixed.Notes: master and stack
9a608c1.original_valuecolumn and states that imported days "remain unfixed until approval". #171 fixes the Original Schedule only through aSTAMPaudit row (repo:1350-1365), which is the option ADR 0008 rejected. The test at line 305-307 depends on the audit-row reader. Whichever PR lands second must writeoriginal_valueat approval and drop the audit-row dependency.getattr(cfg, ...)-on-dict bug for the holiday hours (see P2-5) and should own the CHECK values (see P2-4).Spec axis
Spec: #164, the umbrella and decisions in #163, CONTEXT.md, ADR 0006, 0007 and 0008.
Unless stated otherwise, line numbers refer to
app/db/flow_history_repository.pyat5742d63. Probes ran in a git-archive export with the suite's temp DB. The probe file was deleted afterwards.Verification: these files pass:
tests/test_flow_history_publication.py(9 tests)tests/test_flow_history_curation.py(17 tests)tests/test_calibration_{audit_trust,honesty,reconciliation}.pyruff checkandruff format --checkare clean.r53 ownership check
8c78911) has noflow_history_approved_*table. #171 now creates and writes all three (app/db/database.py:946-995). Done.P1
P1-1. Approval always fails on a database that already ran #170's schema. CONFIRMED.
Where:
app/db/database.py:866and:933.Cause: #171 widens two CHECK constraints inside
CREATE TABLE IF NOT EXISTS:flow_history_curation_drafts.statusgainsAPPROVED;flow_history_curation_audit.actiongainsAPPROVE.IF NOT EXISTSnever changes an existing table, and there is no migration.Probe: I ran
init_schemafrom the #170 tree, then from the #171 tree. Afterwards,UPDATE ... SET status='APPROVED'failed withCHECK constraint failed: status IN ('DRAFT','PREPARED_FOR_APPROVAL','DISCARDED'), and theAPPROVEaudit insert failed the same way.Failure scenario: #170 merges and deploys, then #171 deploys. Every
POST /approverolls back with a 500, so no month can ever be published.Fix: either put both CHECK values in #170, or add a table-rebuild migration in #171. Test it with an upgrade from an existing database.
P1-2. Re-approval destroys the previously published revision. CONFIRMED.
create_curation_draft_record_asyncat ~495-503.flow_history_approved_daysandflow_history_approved_hoursrow for the month and upserts the singleflow_history_approved_monthsrow. Creating the revision-2 draft has already deleted the revision-1flow_history_curation_days, which hold the day reviews and reasons.test_repeated_approval_replaces_published_revisionchecks only staging rows and the count of APPROVE audit rows.(month, published_revision)with a current pointer, or move superseded rows into history tables. The prior snapshot must stay queryable.P2
P2-1. The "differences from a previously approved revision" view is gone. CONFIRMED (code read).
git grepfor approved, previous or diff inapp/schemas/flow_history.pyandapp/services/flow_history_service.py).P2-2. Approval skips the prepare step and does not lock the published draft. CONFIRMED.
app/services/flow_history_service.py:1247checks onlyDISCARDED. The update guards at 831, 907, 960 and 1087 reject onlyPREPARED_FOR_APPROVAL.DRAFTthat was never prepared approved with a 200, so #170's prepare gates, its reason and itsPREPARE_APPROVALaudit are optional.OVERWRITErows).PATCH /draft/days/{d}returned 200 on theAPPROVEDdraft. The draft and the published month now disagree, and another approve would publish the edit without preparation.PREPARED_FOR_APPROVALdrafts.APPROVEDas immutable on every edit path.P2-3. Approval reads the draft outside its transaction and never re-checks it. CONFIRMED.
app/services/flow_history_service.py:1245-1296, beforeBEGIN IMMEDIATE(line 1136). Step 3 (line 1432) updates the draft with no status or revision predicate.store_approved_month_asyncdiscard the draft just before it ran. Approval still published all 31 days and flipped theDISCARDEDdraft toAPPROVED. Its curation days had already been deleted.status = 'PREPARED_FOR_APPROVAL'and the expectedrevision, or useUPDATE … WHERE status=? AND revision=?and checkrowcount.P2-4. Approval rewrites the business-day record of locally lived days as
IMPORTED. CONFIRMED.source='IMPORTED'for every day of the month and recomputesis_exceptionandexception_namefrom the draft'sday_type.EXCEPTIONrecord ("Aniversario",is_exception=1), a FREEZE audit row, and local events in every camera-hour. After approval:sourcewaslocal_events;sourcewasIMPORTED, withis_exception=0andexception_name=None;has_imported_or_mixed_flow_asyncreturnedTrue.source = 'IMPORTED'(app/db/occupancy_repository.py:3047and:3477), and the nightly path filters throughhas_imported_or_mixed_flow_async. The spec excludes only imported-or-mixed days. Publication should not change the calibration eligibility of a lived day.is_exceptionandexception_namefrom the existing record.P2-5. Local precedence is lost once raw events are pruned, and the local rollups are never read. CONFIRMED.
get_month_local_events_async(~985) still reads onlypeople_counting_events. Nothing inapp/readsflow_history_local_*_rollups.LOCAL 99. Afterprune_retention_data_async, it becameHIKCENTRAL 7.P2-6. The rollups count reconstructed estimates and cameras that are not counted. CONFIRMED.
app/db/occupancy_repository.py:3206-3221.people_counting_eventsrow. It has no_COUNTED_CAMERA_FILTERor join, and noreconstructedexclusion. The published KPIs andget_hourly_flow_distribution_asyncapply both.camXXproduced a daily rollup of 899. Every row was labelledsource='local_events'.P2-7. The quality result trusts a Complete day that has no counts. CONFIRMED.
Where: lines 1200-1221.
Cause: when
total_vol == 0, every check is skipped andis_trustedstays 1.Probe: with nothing staged, every day had 288 unresolved hours. All days were set to COMPLETE with a reason, then approved. The result:
status=COMPLETE,enter_total=0,is_trusted=True, the daysourcewas the HikCentral source, and all 288 hours hadsource='NONE'.Spec:
Here the admin's coverage assertion alone produces a trusted, comparison-eligible empty day.
Fix: zero volume, and any
NONEhour on a Complete day, should give an untrusted result with a flag.P2-8. The independent quality result is weaker than the live R1-R4 checks and ignores the admin's configuration. CONFIRMED (code read).
app/services/flow_history_service.py:1282-1286usesgetattr(cfg, …)on the dict returned byget_config_async, so the configured trust thresholds are always replaced by the hard-coded defaults.min(6, trust_min_active_hours), which silently lowers R1 from 10 to 6.cfg.get(...)and the live thresholds. Store the flags with the result.P2-9. A re-approval leaves stale event annotations. PLAUSIBLE (code read, not probed).
(start_date, name)is missing, and nothing removes them.occupancy_events, so the day remains an Event Day for statistics and is excluded from learning. A changed annotation creates a second event.P3
NONEgets the HikCentral source. That is misleading provenance, and it also triggers the calibration exclusion. A neutral value such asnonewould be clearer.app/db/occupancy_repository.py:3244).active_business_day(h_epoch, reset_time)takes the currentdaily_reset_time, not the ADR 0007 effective-dated reset (reset_for(day)). After a reset-time change, old hours are assigned to the wrong business day. Hourly buckets also straddle a reset that is not on the hour.open_window_enteruses whole hours only, so 10:30 becomes 10:00. It returns 0 for a close time after midnight.app/services/occupancy_service.py:2498). It skips mixed days and lived days re-sourced by P2-4. The spec forbids an audit "solely from imported aggregates", so a mixed day's local data could still be audited. Mostly fixed by P2-4.app/db/occupancy_repository.py:3049-3052). The newcycle_date NOT IN (subquery)clause drops logs whosecycle_dateis NULL once any approved day exists, because NULL NOT IN a non-empty set is not true. PLAUSIBLE, since the holiday clause already behaves this way.get_curation_draft_asyncandapprove_curation_draft_asyncin the repository import and call the service, which inverts the layering. The rollback test depends on these shims.get_approved_day_asyncexists in the service, but no route exposes it. Hour-level approved data is unreachable over the API.Master
9a608c1, and master has not changed any of these files since. I expect no textual conflicts.View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.Merge
Merge the changes and update on Forgejo.Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.