HH/docs/workflows/observations.md
2026-07-11 19:24:28 +03:00

31 KiB

Observations — Workflow & Lifecycle

Source of truth: apps/observations/. An "observation" is a staff safety/quality observation (not a patient complaint). This document describes the current implementation only. Every claim is cited as file:line.

⚠ Read first — several stale-doc/latent-bug findings contradict docs/workflows.md and README.md. Most important: the PX "accept/reject" department-response review step documented in docs/workflows.md:44-47 no longer exists in code — the fields were removed (migration 0020). Full list at the end.


1. Purpose

The Observations app is the staff observation / safety-quality reporting module (apps/observations/README.md:3). Any staff member can report issues they notice; submission may be anonymous (no login) with optional Staff ID + Name (README.md:7,13; model docstring models.py:237-246). PX360 staff then triage and route to a responsible department and/or create a PX Action.

It is explicitly not a complaint: no patient complainant, no multi-department join model, no manager-review tier (docs/workflows.md:41-47). The reporter is a staff observer; "patient-facing response" in code (models.py:565) actually means the response shown back to the staff reporter via the public track page.

Anonymous detection: is_anonymous returns True when neither reporter_staff_id nor reporter_name is set (models.py:666).


2. How a Case Starts

2.1 Creation channels (two entry points, both calling ObservationService.create_observation services.py:43)

Channel View URL name Auth Form
Public (anonymous-allowed) observation_create_public (views.py:162) observations:observation_create_public (urls.py:30) → /observations/new/ None ObservationPublicForm (forms.py:29)
Internal (staff) observation_create (views.py:301) observations:observation_create (urls.py:41) → /observations/create/ @login_required ObservationInternalForm (forms.py:181)

Note: PublicObservationForm (forms.py:717) also exists but is not wired to any URL; only ObservationPublicForm is used by the view (views.py:42).

2.2 Public form — required fields (ObservationPublicForm, forms.py:29)

Meta.fields (forms.py:68): hospital, location_type, area, title, description, location_text, incident_datetime, reporter_staff_id, reporter_name, reporter_phone, reporter_email.

  • Required: description (min length 10, forms.py:173); location_type (forced required forms.py:147).
  • Optional: area, title, location_text, incident_datetime (defaulted to now forms.py:163), reporter_* (optional → enables anonymity).
  • No severity/category field on the public form → public submissions always come in as MEDIUM severity, no category until triage/AI assigns them (views.py:188).
  • Spam protection: honeypot website field (forms.py:41, validated forms.py:166). Attachments: ALLOWED_EXTENSIONS/MAX_FILE_SIZE 10 MB (forms.py:25).

2.3 Internal form — required fields (ObservationInternalForm, forms.py:181)

Meta.fields (forms.py:196): hospital, location_type, area, description, section, incident_datetime, patient_file_number, assigned_department, assigned_to, px_source.

  • Required: hospital, location_type, description (min 10).
  • Auto-fills reporter from logged-in user (views.py:322) — internal observations are never anonymous. Sets source_legacy="staff_portal" (vs public "public_form", views.py:204).
  • If assigned_to chosen at creation, the view immediately moves to IN_PROGRESS + writes a status log "Auto-assigned during creation" (views.py:339).

2.4 What happens immediately after submission (ObservationService.create_observation, services.py:43)

Inside one @transaction.atomic (services.py:44):

  1. Observation.objects.create(...) with default status=OPEN (models.py:375).
  2. Tracking code generated by the model's save() override (models.py:650): if adding and hospital_id set → generate_reference("OBS", hospital)OBS-YYYYMM-NNNN (apps/core/reference.py:32); else random OBS-XXXXXX (models.py:26). Uniqueness loop retries.
  3. Initial status log: ObservationStatusLog(from_status="", to_status=OPEN, comment="Observation submitted") (services.py:113).
  4. Attachments created (services.py:118); metadata auto-extracted in ObservationAttachment.save() (models.py:865).
  5. New-observation notification queued to notify_staff_new_item.delay("observation", id) (services.py:126) → send_new_observation_notification (tasks.py:266) → ObservationService.notify_new_observation (services.py:430) emails every user in the "PX Admin" group (services.py:440).
  6. AI analysis queued: analyze_observation_with_ai.delay(id) (services.py:134; task tasks.py:283) — overwrites severity, matches category, sets title, stores result in metadata["ai_analysis"] (tasks.py:347); also drops a bilingual ObservationNote (tasks.py:410).
  7. Public path redirects to success page showing tracking code (views.py:207,225).

Signal side: signals.py only logger.infos creation and status-log events (signals.py:14) — no signal-driven status change.

Token fields (response_token/response_token_used/response_token_sent_at, added 0015) are empty at creation — generated only when sent to a department (views.py:1269).


3. Complete Lifecycle

3.1 The four statuses (models.py:43-49)

OPEN, IN_PROGRESS, RESOLVED, CLOSED.

3.2 VALID_OBSERVATION_TRANSITIONS (models.py:52-57) — verified

OPEN        → {IN_PROGRESS}
IN_PROGRESS → {RESOLVED}
RESOLVED    → {CLOSED, IN_PROGRESS}     # IN_PROGRESS = reopen
CLOSED      → {IN_PROGRESS}             # reopen

Note the two reopen edges: RESOLVED→IN_PROGRESS and CLOSED→IN_PROGRESS.

3.3 Enforcement layers

  • Service: ObservationService.change_status (services.py:144) checks VALID_OBSERVATION_TRANSITIONS and raises ValueError on illegal moves (services.py:166).
  • Model clean(): Observation.clean() (models.py:640) raises ValidationError if status not in the four valid choices. (Validates the value, not the transition.)
  • DB CheckConstraint: observation_status_valid restricting status ∈ {open, in_progress, resolved, closed} (models.py:633), added in 0016:30.
  • ⚠ Bypass: observation_respond (views.py:1001) sets status="resolved" directly, skipping both the service transition check AND the status-log creation (Finding #7).

3.4 Original statuses & the simplification mapping

Original choices (0001_initial.py:151): new, triaged, assigned, in_progress, resolved, closed, rejected, duplicate, contacted (+ contacted_no_response on log fields).

0014_map_statuses.py (data migration):

Old → New status contact_status
new open not_contacted
triaged/assigned/contacted in_progress not_contacted / contacted
rejected/duplicate closed not_contacted

0013_simplify_statuses.py moved "contacted" out of status into a separate contact_status enum (not_contacted / contacted / contacted_no_response, models.py:379-389).

⚠ Stale: tests.py references ObservationStatus.NEW/.TRIAGED/.ASSIGNED which no longer exist — would fail to import. README.md:78 lists the old statuses; README.md:70 the old OBS-ABC123 format.


4. Status Definitions

Status Purpose When entered Who moves forward Next
OPEN Newly submitted, not picked up. Default on create. At creation (services.py:114) Anyone who activates/triages/assigns it (observation_activate views.py:854, observation_triage views.py:729, observation_assign views.py:799) — all flip OPEN→IN_PROGRESS. Send endpoints reject OPEN (activation gate). IN_PROGRESS only
IN_PROGRESS Being worked. activated_at stamped on first entry (services.py:177); due_at computed from SLA. On activate/triage/assign; on reopen from RESOLVED/CLOSED; on assign from resolved/closed PX/Hosp-Admin/PX-Mgmt/PX-Employee/Dept-Manager (varies by view) RESOLVED only
RESOLVED Work complete; awaiting closure. resolved_at/resolved_by stamped (services.py:186). Triggers notify_resolution (services.py:205). Via change_status to RESOLVED; or via observation_respond which auto-resolves when a PX response is sent (views.py:1000) triage_observation perm OR px_admin OR hospital_admin (views.py:773); also anyone allowed to observation_respond (views.py:978) CLOSED, or reopen to IN_PROGRESS
CLOSED Terminal. closed_at/closed_by stamped (services.py:189). Via change_status to CLOSED (same gate) Same Reopen to IN_PROGRESS

Helper: is_active_status True for open/in_progress (models.py:771). check_overdue treats resolved/closed/rejected/duplicate as inactive (models.py:757) — rejected/duplicate literals are harmless leftovers.


5. Workflow Actions

Public (no login)

Action View (views.py) URL name Effect
Submit (public) observation_create_public :162 observation_create_public Creates OPEN; redirects to success page
Success page observation_submitted :225 observation_submitted Shows tracking code
Track by code observation_track :241 observation_track Public status + dept response view (no internal notes)
Token response (champion, no login) observation_respond_with_token :1987 observation_respond_with_token Validates token; champion submits dept response

Internal (login required)

Action View (views.py) URL name Permission gate Effect
Create (internal) observation_create :301 observation_create @login_required Creates OPEN; if assigned_to → IN_PROGRESS
Triage observation_triage :729 observation_triage triage_observation perm OR px_admin ObservationService.triage_observation → sets dept/assignee, → IN_PROGRESS
Change status observation_change_status :765 observation_change_status triage_observation perm OR px_admin OR hospital_admin (:773) — the gate the brief quotes Uses ObservationService.change_status → enforces transitions
Assign/Reassign observation_assign :799 observation_assign px_admin/hospital_admin/px_management/px_employee Sets assigned_to; if resolved/closed → reopen to IN_PROGRESS + clear stamps; if open → IN_PROGRESS
Activate (take) observation_activate :854 observation_activate px_admin/hospital_admin/px_management/px_employee Assigns to self; OPEN→IN_PROGRESS
Reopen observation_reopen :908 observation_reopen px_admin/hospital_admin/px_management/px_employee Only from resolved/closed; → IN_PROGRESS; clears stamps
Add note observation_add_note :945 observation_add_note @login_required ObservationService.add_note
PX outward response observation_respond :973 observation_respond px_admin/hospital_admin/px_management/px_employee OR assigned_to (:978) Sets response/responded_at/responded_by/response_sent_at; auto-resolves if not already resolved/closed (:994). ⚠ Bypasses transition validator & status log.
Generate AI response observation_generate_ai_response :1020 observation_generate_ai_response same as respond Returns bilingual JSON draft; does not persist
Send to department (legacy) observation_send_to_department :1179 observation_send_to_department px_admin/hospital_admin/department_manager/px_management/px_employee Activation gate: rejects OPEN. Sets assigned_department, sent_to_department=True, dept-response SLA, response_token, emails champion token link (§14)
Escalate (manual) observation_escalate :1335 observation_escalate px_admin/hospital_admin/px_management/px_employee Refuses closed/cancelled/rejected; sets escalated_at, emails target
Send-to (AJAX) observation_send_to :1433 observation_send_to px_admin/hospital_admin/department_manager/px_management/px_employee Activation gate. Branch: person (assigns User) or department (emails+SMS champion+manager). ⚠ Reads nonexistent observation.reference_number (:1564) → AttributeError.
Dept response (logged-in) observation_department_response :1612 observation_department_response px_admin OR hospital_admin OR (is_champion AND assigned_department==user.department) Stores department_response_en/ar, stamps department_responded_at/_by, AI summary, notifies reporter
Send dept-response reminder observation_send_dept_response_reminder :1752 observation_send_dept_response_reminder px_admin/hospital_admin/px_management/px_employee Refuses if already responded; emails champion; stamps reminder
Convert to PX Action observation_convert_to_action :1113 observation_convert_to_action px_admin/hospital_admin/px_management/px_employee One-shot guard on action_id; creates PXAction
Soft delete/Restore observation_soft_delete/observation_restore :1961/1975 px_admin/hospital_admin/px_management/px_employee
PDF observation_pdf :2066 observation_pdf @login_required
Category CRUD urls.py:77-83 observations.manage_categories via @permission_required Delete blocked if in use

6. Decision Points

  • Activation gate (shared rule): OPEN observations cannot be sent to a department — both send endpoints reject status == OPEN (views.py:1196, 1457). Must first reach IN_PROGRESS via activate/triage/assign.
  • Triage needed? New observations arrive OPEN with no dept/category (public) or optional dept (internal). Triage sets dept/owner/category (services.py:215).
  • AI classification (tasks.py:283): proposes severity/category/title post-create.
  • Send to person vs department (observation_send_to): recipient_type branch (views.py:1463).
  • Department has a contact target? has_contact_target(dept) gates the dept list; legacy send requires champion or manager (views.py:1212).
  • Contact person valid? department.is_valid_contact_person(contact_person_id) (views.py:1221).
  • Already responded? Reminder short-circuits if department_responded_at set (views.py:1763).
  • Already converted? Convert short-circuits on action_id (views.py:1129).
  • Convert-to-action vs send-to-dept — mutually exclusive paths chosen by PX staff.
  • ⚠ No formal PX accept/reject decision point exists anymore (Finding #1). The implicit "review" is observation_respond.

7. Assignment Flow

  • assigned_department (models.py:445) — the single responsible department (SET_NULL). This is the "outgoing department" field reused by the send flow; there is no separate outgoing-department field, unlike Inquiry.
  • assigned_to (models.py:453) — individual user assignee.
  • assigned_at (models.py:461).
  • Section (models.py:353) — finer-grained.
  • Reassignment: observation_assign logs old→new in the comment (views.py:841); observation_activate reassigns to self (views.py:873).
  • Transfer to department: send endpoints overwrite assigned_department (views.py:1240, 1538) — effectively reassignment + send.
  • Owner cascade get_owner() (models.py:685): section(champion→supervisor→deputy_supervisor) → department(champion→deputy_manager→supervisor→deputy_supervisor→manager_2nd→manager_3rd).
  • Escalation targets computed in detail view from dept.get_role_holders() + Hospital Admin/Department Manager groups (views.py:665).
  • Final owner = whoever resolves/closes (resolved_by/closed_by).
  • SLA config per (hospital, severity): ObservationSLAConfig (models.py:138); Observation.get_sla_config() (models.py:733). dept_response_hours (default 48, models.py:183) drives dept-response SLA.

8. Investigation Process

No separate investigation sub-flow for observations (contrast with Complaints, docs/workflows.md:25-28). No Investigation/Question/Explanation models.

  • Notes = ObservationNote (models.py:879): note, created_by, is_internal (default True). Plus generic notes = GenericRelation("core.Note") (models.py:619).
  • Audit trail = ObservationStatusLog (models.py:904): every status change recorded by change_status (services.py:196). The champion-response path writes a pseudo-log with from_status == to_status (views.py:1668); the token path does not create a status log — small inconsistency.
  • Stage timeline built by _build_observation_stage_timeline (views.py:83) from status logs + timestamp fields.

9. Communication Flow

All notifications via apps.notifications.services.NotificationService.

Event Trigger Recipient Channel Code
New observation create "PX Admin" group email services.py:430
Assignment triage/assign assigned_to email services.py:496 (⚠ notify_assignment defined but never called)
Sent to department send endpoints champion (+manager) email (+SMS in send_to) views.py:1268, 1552
Token link emailed send-to-department contact person email w/ one-time link views.py:1282
Department responded dept response / token reporter (reporter_phone/email) SMS + email w/ track URL views.py:1684, 2039
SLA reminder (resolution) send_observation_sla_reminders assigned_to email tasks.py:53
Dept-response reminders (auto) send_observation_dept_response_reminders dept champion email tasks.py:511
Dept-response reminder (manual) observation_send_dept_response_reminder dept champion email views.py:1752
Resolution/Closure change_status → RESOLVED/CLOSED assigned_to + dept manager email services.py:559
Escalation observation_escalate chosen escalate-to user email views.py:1393
Monthly follow-up (disabled) assigned_to email tasks.py:211 (beat commented out, config/celery.py:193,198)
Public track page observer enters code observer (self-serve) web views.py:241

Acknowledgement/"need more info"/"no observer response" tracked by contact_status (not_contacted/contacted/contacted_no_response, models.py:379), set to contacted on send-to-department (views.py:1255, 1584). No automated "no response" flip — no view currently writes contacted_no_response.

Token-response path: /observations/<uuid:pk>/respond/<str:token>/ (urls.py:36) → observation_respond_with_token (views.py:1987). Validates token, refuses if used, one-shot.


10. Escalation Flow

Manual

observation_escalate (views.py:1335): PX/admin/management/employee picks a Staff target + reason + email subject/body. Refuses closed/cancelled/rejected (views.py:1350). Stamps escalated_at, writes status log + note, emails target.

"Automatic" — present in config but DISABLED in code

  • Config fields on ObservationSLAConfig: dept_response_auto_escalate_enabled (default True, models.py:195), dept_response_escalation_hours_overdue (default 0 = immediately, models.py:198).
  • Seed defaults (seed_observation_dept_response_sla.py:39): sla_hours=72, dept_response_hours=48, first reminder 12h, second 4h, auto-escalate enabled, escalation 0h.
  • Task check_overdue_observation_dept_responses (tasks.py:447) flags overdue but the escalation branch is dead code: always logs "Auto-escalation skipped (disabled)" and continues (tasks.py:499). So escalation is manual only.
  • SLA breach detection: Observation.check_overdue (models.py:757) sets is_overdue/breached_at. Driven by check_overdue_observations (tasks.py:19) every 15 min.
  • Manager involvement: only via manual escalation or the escalation-targets list. No manager-approval tier in the response review.

⚠ Celery beat contradicts task docstrings: all four observation tasks run at crontab(minute="*/15") (config/celery.py:86-114), but tasks.py:56 and :517 claim "every hour". Monthly follow-up beats are commented out (config/celery.py:193,198) — never fire.


11. Resolution Process

  • Who can resolve: the observation_change_status gate (views.py:773) = triage_observation perm OR is_px_admin() OR is_hospital_admin() — as docs/workflows.md:45-46 states. Additionally observation_respond (views.py:978) lets px_admin/hospital_admin/px_management/px_employee or the current assignee resolve by sending a response.
  • How resolved: ObservationService.change_status(..., RESOLVED) stamps resolved_at/resolved_by (services.py:186), writes ObservationStatusLog, fires notify_resolution. The alternate observation_respond sets fields inline (views.py:997) and additionally records responded_at/responded_by/response_sent_at (models.py:570, added 0017).
  • Approval required: ⚠ No separate approval step exists anymore. The historical dept_response_acceptance_status (pending/acceptable/not_acceptable) was removed in 0020. Today the champion's department response is not formally accepted/rejected — PX simply decides whether to write a patient-facing response (which resolves the case) or to reopen/re-ask via notes.
  • Observer confirmation: no observer satisfaction step. 0018 added satisfaction/satisfaction_set_at, but 0019 removed both (0019:13) — so satisfaction is no longer captured for observations.

12. Closure Process

  • When closeable: from RESOLVED only (models.py:55).
  • Who closes: same triage_observation OR px_admin OR hospital_admin gate (views.py:773), via observation_change_status.
  • Side effects: closed_at/closed_by (services.py:189); notify_resolution emailed.
  • Reopen: CLOSED → IN_PROGRESS is legal (models.py:56). Two entry points: observation_reopen (views.py:908, clears resolved/closed stamps) and observation_assign (views.py:825, implicit reopen-on-reassign).
  • Permanently completed: no "archived"/"locked" state — CLOSED is terminal but always reopenable. Soft-delete (views.py:1961) is the only "removal" path, reversible via restore.

13. Exception Flows

  • Duplicate/Invalid/Rejected: former statuses, now collapsed to closed by 0014_map_statuses.py:19-20. Today no dedicated "mark duplicate/invalid" action; PX closes + notes. (UI color maps for rejected/duplicate linger in admin.py:171, models.py:728 but are unreachable.)
  • Wrong department: re-send via send endpoints (overwrites assigned_department) or reassign.
  • Missing info: triage note / ObservationNote; contact_status settable to contacted (no view sets contacted_no_response).
  • No observer response: tracked conceptually via contact_status="contacted_no_response" but no automated transition writes it.
  • Converted to PX Action: observation_convert_to_action (views.py:1113); one-shot guard on action_id; the observation is not auto-closed on conversion.
  • Withdrawn: no dedicated status/action. Anonymous/identified reporter cannot retract; only PX can soft-delete.
  • Merged: no merge feature.
  • Reopened: observation_reopen (views.py:908) from resolved/closed → IN_PROGRESS.

14. Department-Response Sub-Flow

⚠ The documented "champion responds → PX accepts/rejects → resolve" loop (docs/workflows.md:44) is only partially implemented today. The accept/reject tier was removed (0020). What remains is "champion responds → PX writes outward response (resolve)". Both the intended design and the actual code are documented below.

14.1 Migration history of the sub-flow

  • 0004_add_sent_to_department_fields.py:13 — added sent_to_department (bool) + sent_to_department_at.
  • 0005_data_sent_to_department_backfill.py:5 — backfilled sent_to_department=True for any observation with an assigned_department.
  • 0001_initial.py:165-175 — original dept-response fields.
  • 0001_initial.py:176-178 + 0020:13-28dept_response_acceptance_status (+_at/_by/_notes) added then removed. Net effect: no acceptance fields today.
  • 0015_add_response_token.py:13response_token/response_token_used/response_token_sent_at.
  • 0017_observation_responded_at...py:15responded_at/responded_by/response/response_en/ar/response_sent_at (the PX outward response).

14.2 The activation gate (shared rule)

Both send endpoints refuse status == OPEN:

  • observation_send_to_department: views.py:1195 → "Activate this observation before sending it to a department."
  • observation_send_to: views.py:1456 → HTTP 400 JSON. So an observation must first reach IN_PROGRESS (via triage/activate/assign) before forwarding.

14.3 Send → receive

  1. Send (observation_send_to_department views.py:1179 or observation_send_to views.py:1433):

    • Sets assigned_department = department (views.py:1240, 1538).
    • Sets sent_to_department = True, sent_to_department_at = now, forwarded_to_dept_at = now (views.py:1241-1243, 1539-1541) — the uniform cross-module "sent" signal.
    • Computes dept_response_sla_due_at = now + dept_response_hours; resets overdue/reminder/escalation stamps (views.py:1245-1252, 1543-1550).
    • Sets contact_status="contacted" (views.py:1255, 1584).
    • Legacy path generates response_token (views.py:1273) and emails the chosen contact person a one-time link https://{domain}/observations/{pk}/respond/{token}/ (views.py:1282).
    • Unified path emails+SMSes champion and manager via get_champion_and_manager — but ⚠ crashes on observation.reference_number (views.py:1564-1574, Finding #2 — reference_number doesn't exist on Observation, only tracking_code).
    • Writes an internal ObservationNote (views.py:1261, 1590).
  2. Who receives it: the department champion primarily (reminder recipient is dept.champion.user, tasks.py:542); the dept manager is a secondary recipient in the unified send path.

14.4 Champion's response submission (two surfaces, same fields)

  • Logged-in champion: observation_department_response (views.py:1612), gated by is_champion() AND observation.assigned_department == user.department (views.py:1619). Form: ObservationDepartmentResponseForm (forms.py:654) — requires response_en or response_ar (forms.py:682).
  • Token (no login): observation_respond_with_token (views.py:1987), validates response_token (:1998) and response_token_used (:2001).

Both store department_response_en/ar, stamp department_responded_at + department_responded_by (logged-in path only), clear dept_response_is_overdue, mark response_token_used=True (token path), generate AI summary into department_response_summary_en/ar (views.py:1644, 2022), write a pseudo ObservationStatusLog (logged-in path only, views.py:1668), and notify the reporter via SMS/email with the public track URL (views.py:1684, 2039).

14.5 ONE-level review — what actually exists

  • Per docs/workflows.md:41-47 (intended): "champion responds → PX accepts/rejects → resolve" using dept_response_acceptance_status.
  • In current code: dept_response_acceptance_status and friends were removed (0020). There is no px_accept/px_reject URL or view. The de-facto "review" is the PX user running observation_respond (views.py:973) to write response/response_en/ar, responded_at/_by, response_sent_at (models.py:565), which also flips status to resolved (views.py:1000).
  • ⚠ Reject loop / "response cleared, returns to champion": described in docs/workflows.md:16-17 as a shared rule, but there is no implementation for observations — no view clears department_response_en/ar or resets department_responded_at. If PX is unhappy, the only options today are to reopen (observation_reopen) and re-send, or to add a note. Document as a gap.

14.6 Token-response path (summary)

  • URL: /observations/<uuid:pk>/respond/<str:token>/ (urls.py:36).
  • Token generated only at send-time (views.py:1273), stored in response_token (models.py:551), uniqueness-indexed.
  • Validation: token equality + non-empty (views.py:1998); not already used (views.py:2001, field response_token_used models.py:555).
  • One-shot: sets response_token_used=True on submit (views.py:2019); subsequent visits render response_already_submitted.html.
  • No-auth requirement explicit (views.py:1987).

15. End-to-End Example

Staff member submits observation via public form (anonymous allowed)
  ↓ (views.py:162)  → status=OPEN, tracking OBS-202607-0001,
                      initial status log written, AI analysis dispatched,
                      PX admins notified
PX triages (sets department, assignee, category, severity)
  ↓ (views.py:729)  → status=IN_PROGRESS, activated_at, due_at computed
                      [DECISION: send to department or convert to action]
PX sends to Department X
  ↓ (views.py:1179)  → assigned_department=X, sent_to_department=True,
                       dept_response_sla_due_at set, response_token generated,
                       token link emailed to champion, contact_status=contacted
                       [DECISION: champion responds via token or logged-in]
Champion submits dept response via token link
  ↓ (views.py:1987)  → department_response_en set, department_responded_at,
                       response_token_used=True, AI summary generated,
                       reporter notified SMS+email
                       [⚠ NO PX accept/reject — field removed]
                       [DECISION POINT: PX must write outward response]
PX writes outward response
  ↓ (views.py:973)  → response set, responded_at/by, response_sent_at,
                      status=resolved (auto), resolved_at/by
                      [⚠ bypasses transition validator & status log]
                      [DECISION POINT: close or reopen]
PX closes
  ↓ (views.py:765)  → status=closed, closed_at/by, notify_resolution
                      [PERMANENT unless reopened → IN_PROGRESS]

Appendix — Flagged gaps vs docs/workflows.md / README.md

  1. dept_response_acceptance_status removed (0020) — PX accept/reject no longer implemented; no px_accept/px_reject view.
  2. observation_send_to AttributeError (views.py:1564-1574) — reads nonexistent observation.reference_number; should be tracking_code.
  3. Stale test suitetests.py references ObservationStatus.NEW/.TRIAGED/.ASSIGNED.
  4. Celery beat contradicts task docstrings — all run */15, not "every hour"; monthly follow-up beats commented out.
  5. Auto dept-response escalation disabled (tasks.py:499) despite config flag defaulting True.
  6. README.md outdated — old statuses, old tracking format.
  7. observation_respond bypasses transition validator & status log (views.py:1001) — inconsistency in audit trail.
  8. No reject loop for observations (no view clears dept response) — gap vs docs/workflows.md:16-17.
  9. satisfaction removed (0019) — no observer satisfaction step.
  10. Stale literalscheck_overdue/escalate reference cancelled/rejected/duplicate which can never occur.