Bulk soft-delete or hard-delete Contract V3 rows for a customer workspace from production shell. Trigger on: 'bulk delete contracts', 'permanent deletion', 'workspace WSID', '196 contracts', 'phonepe bulk delete', 'UpdateStatus DELETE HARD_DELETE', 'contracts stuck VOID', 'alice+phonepe', 'ContractV3Service.update_contract_status', 'on-call script phonepe_bulk_delete'. Covers two-phase DELETE then HARD_DELETE, org_user auth context, Metabase verification, Rootly task incidents.
Symptom / request: Support or CS needs to remove many contracts from a tenant workspace (e.g. {workspace_id}), often with legal/compliance wording like “permanent deletion.” Engineering executes a controlled Django shell script that calls ContractV3Service.update_contract_status, not raw SQL.
Pattern: Two-step workflow when mixed contract states exist (including void or non-terminal states):
UpdateStatus.DELETE → DB status DELETED (soft delete; product deletion hooks: invites, comms, manual tasks, search resync, audits).UpdateStatus.HARD_DELETE → HARD_DELETED only if contracts are already DELETED; otherwise ContractHardDeleteNotAllowed.Confirmed example (PhonePe, production, IN cluster): Rootly incident — contracts, , attribution user , Jira .
created_by_workspace_id = 5092| Use this skill | Do not use |
|---|---|
| Customer-approved bulk delete with explicit ID list + workspace | Ad-hoc delete without CS/legal sign-off |
| Need product semantics (audit, ES, relations, packets) | Raw DELETE FROM contractv3 (bypasses hooks) |
IDs verified in Metabase / BQ for {workspace_id} | Wrong workspace or partial ID list |
Soft delete maps to ContractStatus.DELETED:
if update_status == UpdateStatus.DELETE:
return ContractStatus.DELETED
Hard delete requires current status DELETED:
if update_status == UpdateStatus.HARD_DELETE:
if current_status != ContractStatus.DELETED:
raise ContractHardDeleteNotAllowed(
"Only deleted contracts can be permanently deleted."
)
return ContractStatus.HARD_DELETED
After HARD_DELETED, _handle_post_hard_delete_actions runs (relations, contract events, packet removal, etc.) — see contracts_v3/services/contract_service.py.
ContractV3Service.update_contract_status bulk-updates search and, for DELETE only, calls _post_contract_delete_actions — not for HARD_DELETE (post-actions differ at ContractService layer).
Reference implementation (adjust IDs, WORKSPACE_ID, USER_EMAIL, DRY_RUN):
scripts/oncall_mitigations/phonepe_bulk_delete_contracts.pyConstants:
CONTRACT_IDS: explicit list (count and IDs verified in Metabase on contracts_v3_contractv3: created_by_workspace_id = {workspace_id} and id in (...)).USER_EMAIL: must resolve to a User who has an OrganizationUser for that workspace (used for updated_by / audits). Example from incident: {user_email} → confirm via Metabase on org user / auth user tables.STATUS: UpdateStatus.DELETE for phase 1; UpdateStatus.HARD_DELETE for phase 2.Auth context:
SDBaseServiceAuthContext with org_user=OrganizationUserService.get_org_user_by_user_id_and_workspace(user_id=user.id, workspace_id=workspace.id) — do not pass org_user=None with a non-None context ( ensure_object_permission asserts). Alternative: auth_context=None on update_contract_status (skips object-level permission enforcement; workspace scoping still applies on contract fetch).Dry run: set DRY_RUN = True; script should not call update_contract_status — only validates user, workspace, contract IDs, and prints intent.
Real run: DRY_RUN = False; prefer transaction.atomic() around the service call.
Before soft delete: IDs exist and belong to workspace:
created_by_workspace_id = {workspace_id}, id in ({contract_id_list}).Before hard delete: all targets status = 'DELETED' (or run fails with ContractHardDeleteNotAllowed).
After hard delete: status = 'HARD_DELETED' for all IDs.
Use saved questions or inline filters; incident used metabase.in.spotdraft.com question links with database: 2, source-table: 75 (table id for Contract V3 — confirm in your env if schema differs).
slack_read_channel — incident channel (e.g. #incident-*).slack_read_thread — review script approval / Metabase links in threads.spotdraft-prod.prod_{region}_db.public_contracts_v3_contractv3 with created_by_workspace_id and status.{region} to cluster (india, eu, usa, mea).update_contract_status or Celery follow-up tasks fail; filter by contract_id, workspace_id, error text.created_by_workspace_id; note any VOIDED / non-deletable states — may require soft delete first.STATUS = DELETE, DRY_RUN true → then false; re-check Metabase DELETED.STATUS = HARD_DELETE, dry run → execute; verify HARD_DELETED.| Pattern | Signal | Action |
|---|---|---|
| Hard delete without soft delete | ContractHardDeleteNotAllowed | Run DELETE first; re-verify DELETED |
Wrong org_user / email | OrganizationUserNotFound | Fix USER_EMAIL or org membership |
auth_context + org_user=None | AssertionError: org user cannot be None | Resolve org user or use auth_context=None |
| Partial ID list | Some rows wrong workspace | Re-filter Metabase |
HARD_DELETED — not reversible via normal product flows; involve DBA / backup policy.get_status_update_for_contract and workflow state (escalate if not straightforward DELETE).