Status-Transition Event Log (migration 031)
Shipped: 2026-08-05 (entirely-api 4c4cd39 + test guard 5c3601b)
Every project lifecycle status change now writes an append-only row to
Supabase project_status_events (project_id, field, old_value, new_value,
caller, changed_at). First deliverable of the ML/workflow-intelligence plan:
the 2026-08-05 readiness audit found only current status was stored, so
process mining and the planned Shepherd stall model had no transition
history. History accrues from deploy forward.
Write paths (all in services/supabase_write_service.py)
update_record(projects) — diffsrequest_status,cert_status,invoice_status,on_holdagainst the pre-write row (select extended to include them; one read, no extra round-trip).create_record(projects) — logs initial values withold_value = NULL(“born with this status”), anchoring each timeline at intake.update_project_triage_columns— coverstriage_status(pre-reads only when that column is in the write).update_project_ppdr_columns— coversdeclined.
Logic lives in services/status_event_service.py (diff_tracked,
log_status_events). Booleans stored as “true”/“false”; statuses as display
labels. Insert failures log ERROR with traceback and never break the parent
write — the project row is the truth, the event row is derived.
Gotcha discovered at deploy
The host-side pytest suite runs against the real .env; tests mocking
supabase_write_service.get_client still let the event logger reach real
Supabase via its own client import — 2 fake-project rows leaked on the first
suite run (deleted). tests/conftest.py autouse fixture now stubs
status_event_service.get_client for every test. Broader hermetic-test
audit spawned as a follow-up task.
Consumers (planned)
- Process mining / cycle-time analytics (event log is the substrate)
- Shepherd v2 stall-risk scoring (needs months of accrued transitions)
- Confidence-routing calibration
Related: shepherd-report-dir-mount, memory project_ml_readiness_audit.