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) — diffs request_status, cert_status, invoice_status, on_hold against the pre-write row (select extended to include them; one read, no extra round-trip).
  • create_record (projects) — logs initial values with old_value = NULL (“born with this status”), anchoring each timeline at intake.
  • update_project_triage_columns — covers triage_status (pre-reads only when that column is in the write).
  • update_project_ppdr_columns — covers declined.

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.