Company hard-delete lifecycle
Scope: Platform staff hard-delete of a canonical Core company UUID (
companies.id). Distinct from Promoters self-serve retire (DELETE /api/users/companies/:id) for empty shells with no package.
1. Purpose
Section titled “1. Purpose”Hard-delete permanently removes a tenant company and all owned data across Kisum services. It is:
- Irreversible — no soft-delete recovery path after Core removes the company row.
- Orchestrated — Admin BFF starts an async job; Core owns the audit row (
company_delete_jobs). - Cross-service — each persona/addon backend exposes machine-only
delete-preview+purge(or Core-native delete for Core-owned tables). - Shared-data safe — booking graph rows, promoter events, and similar shared references can block delete or be repointed to the platform tombstone when
force=true.
Who runs it: Platform staff via Frontend-Kisum-Admin → Backend-Kisum-Admin (/api/v1/admin/companies/{companyId}/…). Browsers must never call Auth/Core/persona internal routes directly.
2. Tombstone company UUID
Section titled “2. Tombstone company UUID”| Field | Value |
|---|---|
| Canonical UUID | 00000000-0000-0000-0000-000000000001 |
Core legal_name / display_name | [Deleted organization] |
Core status | archived |
metadata_json | { "tombstone": true } |
Validation note: the tombstone UUID has zeroed version/variant bits, so it is not a strict RFC-4122 UUID. Services must not validate
tombstoneCompanyIdwith a strict version/variant regex — either use a lenient parser (Gouuid.Parse) or explicitly allow the canonical tombstone constant (fixed in Finance 2026-07-08).
Rules (DO NOT BREAK):
- The tombstone company is never purgeable and never receives a delete job.
- It is not a login target and must not be billed.
- When a tenant is deleted, shared foreign keys in Artists/Venues (and similar) may be repointed to this UUID before the tenant row is removed (
force=trueon purge). - Global Finance categories (
companyId = null) and other platform-global rows are never deleted as part of tenant purge.
Seeded by Core migration 00018_company_hard_delete.sql.
3. Async job flow
Section titled “3. Async job flow”sequenceDiagram
participant AdminUI as Frontend Admin
participant AdminBFF as Backend Admin
participant Core as Backend Core
participant Workers as Purge workers
participant Svc as Auth Promoters Finance Artists Venues
AdminUI->>AdminBFF: GET delete-preview
AdminBFF->>Core: GET internal delete-preview
AdminBFF->>Svc: GET internal delete-preview (parallel)
AdminBFF->>Checkout: GET internal delete-preview
AdminBFF-->>AdminUI: merged inventory + blockers
AdminUI->>AdminBFF: POST delete (force, reason, confirmName)
AdminBFF->>Core: POST internal delete-jobs
Core-->>Core: status=deleting, job=pending
AdminBFF-->>AdminUI: jobId
loop Poll until completed or failed
AdminUI->>AdminBFF: GET delete-status/{jobId}
AdminBFF->>Core: GET internal delete-jobs/{jobId}
Note over AdminBFF,Core: Admin PATCH steps / POST complete|fail after each step
AdminBFF-->>AdminUI: effectiveStatus + human-readable steps
end
Note over Workers,Svc: Background orchestration (Admin worker)
Workers->>Checkout: POST cancel-billing
Workers->>Core: PATCH delete-jobs/{jobId}/steps (after each step)
Workers->>Svc: POST purge per service (parallel personas)
Workers->>Auth: POST purge
Workers->>Core: DELETE internal company (hard, cascade)
Workers->>Core: POST delete-jobs/{jobId}/complete
Job states (company_delete_jobs.status)
Section titled “Job states (company_delete_jobs.status)”| Status | Meaning |
|---|---|
pending | Job created; company locked deleting; worker not started or queued |
running | At least one purge step in progress (steps_json updated) |
completed | All steps succeeded; company row gone from Core |
failed | Step error recorded in error_message; ops intervention required |
Typical purge order (Admin orchestrator)
Section titled “Typical purge order (Admin orchestrator)”- Checkout — cancel Xendit recurring plan/mandate; clear Redis provision progress.
- Promoters, Finance, Artists, Venues — parallel
POST purge. - Admin — company-scoped S3 prefixes (
companies/{coreCompanyId}/). - Auth — memberships, invitations, sessions; orphan-only user hard-delete (
mode: full). - Core —
DELETE /internal/companies/{companyId}(cascade owned commercial/profile/BU rows).
Exact step keys in Admin job overlay: checkout_cancel_billing, persona_purges (sub-steps: promoters, finance, artists, venues), admin_s3_purge, auth_purge, admin_local_cleanup, core_hard_delete, cache_invalidation.
Persistence: Admin orchestrator must write progress to Core after every step (PATCH …/delete-jobs/{jobId}/steps). Terminal states use POST …/complete or POST …/fail. This keeps poll status accurate across load-balanced Admin instances and survives process restarts.
Resume: POST …/delete with optional resumeJobId re-runs the orchestrator from the last completed step (POST …/delete-jobs/{jobId}/resume on Core). Frontend shows Continue delete when a job fails.
Company lock
Section titled “Company lock”When a delete job is created, Core sets companies.status = 'deleting'. Login and commercial mutations should fail closed until the job completes or is manually recovered.
Core hardening (migration 00019)
Section titled “Core hardening (migration 00019)”- Job survival:
company_delete_jobshas no FK tocompanies— job rows are the permanent audit trail and survive the final purge. - Single active job: at most one
pending/runningjob per company (partial unique index).POST …/delete-jobson a company with an active job returns409 delete_job_activewith the existingjobId+statusin the error payload; race-condition creates hit the index and return the same 409 shape. Job creation is atomic (blocker check + insert +deletinglock in one transaction). - Mutation freeze: while
status = 'deleting', Core rejects company mutations with409 company_deleting(PATCH company — including moving status offdeleting— basic/addon subscription upserts, business-unit create, profile, addresses, documents,activate-signup). Delete-lifecycle routes keep working. - Guarded final DELETE:
DELETE /internal/companies/{companyId}requiresstatus = 'deleting'and an existing delete job, else409 delete_not_locked.alreadyPurged: trueis returned only when the company row is gone and a delete-job row proves it existed; an unknown UUID with no job history is a plain404. - Job state machine:
steps/complete/failare accepted only frompending/running(terminal jobs →409);failclearscompletedAt; every job write refreshesupdatedAt.steps/failbodies are capped at 1 MiB.
Admin orchestration hardening (2026-07-09)
Section titled “Admin orchestration hardening (2026-07-09)”- Concurrency-safe status: the in-memory run state is mutex-guarded (fixes a fatal concurrent-map crash while polling during a run); Core terminal status wins over local state and evicts the local entry.
- Duplicate/resume gating: Core’s
409 delete_job_activeis forwarded to the browser as409 delete_job_exists(+jobId/status) so the UI attaches to the running job. Resume is free forfailed/pending; arunningjob can be resumed only when CoreupdatedAtis >90s stale (dead-runner lease), else409 delete_job_running. Resume always uses the job’s storedforceDelete. effectiveStatus: "stalled": Core reports running/pending, no local runner exists, andupdatedAtis >90s old — the UI offers Restart worker.- Preview: adds
canForcePurge(true when every blocker is force-okay); unconfigured upstreams surface as non-forceable<service>_not_configuredblockers so preview and pipeline agree. - S3 purge: company uploads keyed by full UUID; purge sweeps
companies/<uuid>/plus the legacycompanies/<first-8>/prefix with batchedDeleteObjects(≤1000/batch) failing on per-object errors. - Caching:
delete-status/delete-previeware excluded from the Admin Redis GET cache; company cache invalidation pattern fixed to match path-keyed entries. - Persistence: every Core steps/complete/fail write is retried 3× and logged on failure (never silently dropped); persona sub-step results are preserved across section restarts.
4. Per-service data matrix
Section titled “4. Per-service data matrix”Legend: Owned = deleted with tenant; Shared = may block or repoint to tombstone; Blocker = preview returns a code; None = no durable tenant store / not in purge path.
| Service | Ownership | What is counted / purged | Shared / blockers | Machine routes |
|---|---|---|---|---|
| Core | Owned | companies (final step), company_profiles, company_addresses, company_social_links, company_documents, company_subscriptions, company_addons, company_entitlement_versions, entitlement_history, business_units | Blocker: tombstone_company (never). active_billing_subscription when billing customer + active sub (forceOk) | GET …/delete-preview, DELETE …/{id}, POST/GET/PATCH …/delete-jobs (+ /steps, /complete, /fail, /resume) |
| Auth | Owned | company_memberships, business_unit_memberships, invitations, membership grants, member sessions (revoked in Postgres) — all deletes in one atomic transaction; orphan-user delete re-checks membership in another company inside the tx | Orphan-only users hard-deleted; multi-company users kept (they lose ALL sessions — sessions are not company-scoped). Protected platform orphans may block unless force. Blocker: tombstone_company (constant tombstone id; request body cannot override). Session-revocation failures never fail the purge — response includes revocationErrors. Redis session cache invalidation is best-effort — purge must complete when Redis is unavailable (Postgres is SoT). | GET/POST /internal/companies/{id}/delete-preview|purge |
| Promoters | Owned | Mongo cascade (children before parents): event children eventExpense, eventIncome, eventTax, eventTicket, eventTerm, eventPayment, eventOffer, vendorsEvent (+ 2nd level eventExpenseLog, eventTicketSoldHistory) by resolved event ids; taskComment/taskActivity by task ids; companyUserPermissions/companyTeamPermissions before companyUsers/companyTeams; direct company-keyed events, eventGroup, task, roster, transactions, offerAgreementTemplates, companyUserInvitations; legacy-ObjectId keyed offer (buyer), avails (agency); financeIntegrationLog (companyId = UUID or legacy string); legacy companies shell. Excluded (user-keyed — users span companies): approvalRequest, notification, conversation, message, userOnboard. Excluded (rostr directory — companyId refs agencies): agencies, agenciesRoster, agenciesTeam. Preview uses the same cascade plan as purge. Core legacy-id lookup is fail-loud: lookup failure → 500 (Admin retries); only a Core-confirmed “no legacy id” proceeds UUID-only | Idempotent via company_tenant_purges marker (force bypasses) | GET/POST /internal/companies/:companyId/delete-preview|purge |
| Finance | Owned | Prisma: invoice, income, incomeTerm, incomePayment, vendor/customer links, company categories, approval policies/steps, Xero connection/logs, audit logs, payments, invoice files, etc. | Blockers: ACTIVE_XERO_PIPELINE, ACTIVE_XERO_SYNC. Global categories (companyId=null) not deleted | GET/POST /api/internal/companies/{id}/delete-preview|purge |
| Artists | Owned + Shared | Core-UUID columns across booking graph, contracts, touring; middle-agent workspace anchored on middle_agent_operators (core_company_id) — purge deletes the operator row (cascades middle_agent_* / workspace_artists child rows). Bridged directory companies via artists_company_claims — approved claims only, skipped when another core company also holds an approved claim (cross-tenant safety). Booking-workflow children deleted by parent id only when the parent is deleted; survivors repointed to tombstone. middle_agent_partner_relationships partner-side rows deleted; middle_agent_listing_reports.reporter_core_company_id repointed on surviving listings | Blockers: shared booking rows. force repoints shared side to tombstone. Preview includes operatorIds | GET/POST /internal/companies/:companyId/delete-preview|purge; optional POST /internal/middle-agent-operators/:orgID/deactivate|activate |
| Venues | Owned + Shared | All company_id venue ops tables (spaces, bookings, contracts, inventory, team, automation, …), deleted in FK-safe order (events before bookings; the event_id/booking_id FK cycle is broken by NULLing event_id first) | Blockers: SLEEPING_VENUES, SHARED_PROMOTER_EVENT. force repoints shared promoter_event_id links to the tombstone, plus any purged venues/bookings/events still referenced by surviving rows (kept, not deleted). market_company_id counterparty refs (bookings/customer-overlays/contracts) are always repointed; preview returns counterpartyRefs, purge returns repointed counts. Tombstone purge/preview → 409; tombstoneCompanyId == companyId → 400 | GET/POST /internal/companies/:companyId/delete-preview|purge |
| Checkout | Billing handoff | Redis provision-progress keys; every Xendit recurring plan recorded in Core metadata (metadata.xendit.recurring_plan_id + previous_plan_ids history written by add-on upgrades) — each verified by GET and deactivated when ACTIVE/PENDING/REQUIRES_ACTION | Blocker: active_xendit_subscription (not force-ok) when any recorded plan is still active. Xendit 404/DATA_NOT_FOUND per plan → treated as already gone. Cancel-billing: Core company 404 → alreadyCancelled success (resume path); tombstone company → 409. Cancel step runs before persona purges | GET/POST /internal/companies/{companyId}/delete-preview|cancel-billing |
| Admin | Blocker / storage | No company master SoT; may hold S3 company logos/documents uploaded via Admin file flows | Purge object prefixes when orchestrated | Browser routes + orchestrator worker |
| MusicData | None | Analyst/chart data keyed by artist IDs, not Core company UUID | Not in company purge matrix | — |
| RAG / MCP / Chat | Audit note | Conversation memory and tool audit logs may reference x-org / company UUID in metadata | Not authoritative tenant stores; optional async scrub or retain for compliance — document retention policy separately; purge tools must not treat Qdrant/MCP Postgres as SoT for company master | No dedicated purge API (2026-07-08) |
Object storage (Promoters + Admin)
Section titled “Object storage (Promoters + Admin)”Promoters uploads are not company-prefixed in S3 (e.g. app/primuse-crm/events/{uuid}, app/tasks/{taskId}/attachments/…), so the purge’s primary mechanism is exact object keys collected from the Mongo docs being purged (collected before the doc deletion, deleted after it succeeds): events.poster, eventGroup.poster, task.attachments, taskActivity.attachments, eventTerm.invoiceUrl, eventPayment.receiptUrl. All Promoters tenant uploads live in the images bucket (BUCKET_AWS_IMAGES). Any S3 failure (including per-object DeleteObjects errors inside an HTTP 200) fails the purge step — Admin retries; the purge marker is only written after full success.
| Key / prefix pattern | Owner | Action on purge |
|---|---|---|
| Exact keys parsed from purged docs’ URLs | Promoters S3 (images bucket) | Delete listed objects (primary) |
companies/{coreCompanyId}/ | Promoters S3 | Extra sweep (legacy layout) |
app/primuse-crm/companies/{coreCompanyId}/ | Promoters S3 (legacy) | Extra sweep (legacy layout) |
companies/{legacyMongoId}/ | Promoters (if legacy bridge exists) | Extra sweep when legacy id resolved |
| Admin company logo/document keys | Admin S3 | Delete per Admin orchestration step |
5. API contracts
Section titled “5. API contracts”5.1 Admin browser (Frontend-Kisum-Admin → Backend-Kisum-Admin)
Section titled “5.1 Admin browser (Frontend-Kisum-Admin → Backend-Kisum-Admin)”All routes require platform staff Bearer JWT. Base: /api/v1/admin.
GET /companies/{companyId}/delete-preview
Section titled “GET /companies/{companyId}/delete-preview”Purpose: Aggregated inventory before confirm.
Response data (illustrative):
{ "companyId": "uuid", "companyName": "Acme Promoters Ltd", "canPurge": false, "blockers": [ { "code": "active_billing_subscription", "message": "company has an active subscription linked to a billing customer", "forceOk": true, "service": "core" } ], "services": { "core": { "owned": { "subscriptions": 1 }, "blockers": [] }, "auth": { "counts": { "companyMemberships": 3 }, "userImpact": { "orphanUsers": 1 } }, "promoters": { "counts": { "events": 12 }, "totalRows": 540 }, "finance": { "counts": { "invoices": 4 }, "blockers": [] }, "artists": { "counts": { "bookings.created_by_core_company_id": 2 }, "blockers": [] }, "venues": { "counts": { "venues": 1 }, "blockers": [] } }}POST /companies/{companyId}/delete
Section titled “POST /companies/{companyId}/delete”Body:
{ "force": true, "reason": "Customer requested GDPR erasure — ticket #1234", "confirmName": "Acme Promoters Ltd", "resumeJobId": "optional-uuid-to-continue-after-failure"}Validation:
confirmNamerequired; must match CorelegalNameordisplayName(case-insensitive).- Non-
forceOkblockers →409 delete_blockedunlessforce: true. - Tombstone company →
403 delete_forbidden.
Response: 202 + { "jobId": "<uuid>" } — async orchestration starts in Admin.
GET /companies/{companyId}/delete-status/{jobId}
Section titled “GET /companies/{companyId}/delete-status/{jobId}”Purpose: Poll async progress (Admin step overlay merged with Core company_delete_jobs row).
Response data: status, effectiveStatus (use for polling), optional orchestratorStatus, steps, errorMessage, timestamps.
Per-step timing: Each step (and each persona sub-step) carries startedAt, finishedAt, and durationMs. Running steps have startedAt + status: "running" before completion. The persona_purges section also records its own wall-clock startedAt / durationMs (parallel, so shorter than the sum of its sub-steps).
UI: Frontend renders one human-readable row per step (not raw JSON), with a live/final duration per row and per persona sub-row, plus a Total elapsed timer in the header. On failed, polling stops and Continue delete sends resumeJobId.
Breaking: DELETE /companies/{companyId} returns 405 — use POST …/delete.
5.2 Internal machine routes (orchestrator only)
Section titled “5.2 Internal machine routes (orchestrator only)”| Service | Preview | Purge / delete |
|---|---|---|
| Core | GET /internal/companies/{companyId}/delete-preview | POST …/delete-jobs, GET …/delete-jobs/{jobId}, DELETE …/{companyId} |
| Auth | GET /internal/companies/{companyId}/delete-preview | POST …/purge body { "mode": "full", "force?", "tombstoneCompanyId?" } |
| Promoters | GET /internal/companies/:companyId/delete-preview | POST …/purge same body shape |
| Finance | GET /api/internal/companies/{companyId}/delete-preview | POST …/purge |
| Artists | GET /internal/companies/:companyId/delete-preview | POST …/purge |
| Venues | GET /internal/companies/:companyId/delete-preview | POST …/purge |
| Checkout | GET /internal/companies/{companyId}/delete-preview | POST …/cancel-billing |
Auth headers: X-Internal-API-Key (or service-specific key: AUTH_INTERNAL_API_KEY, CORE_INTERNAL_API_KEY, ARTISTS_INTERNAL_API_KEY, VENUE_INTERNAL_API_KEY, CHECKOUT_INTERNAL_API_KEY, Finance FINANCE_INTERNAL_API_KEY as Bearer, Promoters internal key). Legacy Finance INTERNAL_API_KEY bearer still accepted for Promoters/cron. Never expose these to browsers.
Purge body defaults: tombstoneCompanyId defaults to 00000000-0000-0000-0000-000000000001; mode = "full".
6. Cross-company tombstone rules
Section titled “6. Cross-company tombstone rules”- Repoint, don’t orphan — When a booking or promoter-event row references two companies and only one is deleted, the deleted tenant’s side is set to the tombstone UUID so the surviving company’s data stays coherent.
- Tombstone is read-only — No memberships, subscriptions, or login for tombstone; purge APIs reject it.
- Force is explicit — Operators must enable force in Admin UI and provide a reason when overriding
forceOkblockers (e.g. active billing). - Hard blockers — Blockers without
forceOk(e.g. non-force shared graph conflicts, tombstone) cannot be overridden. - Idempotency — Re-running purge on an already-purged service returns
alreadyPurged/ idempotent success; orchestrator should mark the step complete and continue. - Canonical ID — All machine calls use Core company UUID (
x-orgcanonical), not legacy Mongo_id. Promoters resolves legacy id internally for Mongo + S3.
7. Ops runbook — stuck or failed jobs
Section titled “7. Ops runbook — stuck or failed jobs”7.1 Identify state
Section titled “7.1 Identify state”- Admin UI → company detail → delete drawer poll, or Core SQL:
SELECT * FROM company_delete_jobs WHERE company_id = '<uuid>' ORDER BY created_at DESC LIMIT 1;
- Inspect
steps_jsonfor last completed step key anderror_message. - Confirm company row:
SELECT id, status FROM companies WHERE id = '<uuid>';(deleting= mid-flight).
7.2 Common failures
Section titled “7.2 Common failures”| Symptom | Likely cause | Action |
|---|---|---|
Job failed on promoters | Mongo/S3 timeout or missing internal key | Fix creds/network; POST Promoters purge again with same companyId; update job step manually or restart job from Admin |
Job failed on finance | Active Xero sync | Complete/cancel Xero pipeline or re-run with force=true after ops approval |
Job failed on artists / venues | Shared booking/event without force | Re-run preview; if expected, restart delete with force=true |
Job running > 30 min | Worker crash | Check Admin/Core logs; resume purge from failed step (idempotent services) |
Company stuck deleting, no job | Partial create | Insert/repair job row or set company inactive after manual verification — escalate before manual SQL |
| Company gone but orphan data in one service | Step skipped | Run that service’s POST purge with machine key; verify counts in delete-preview = 0 |
7.3 Recovery principles
Section titled “7.3 Recovery principles”- Never delete tombstone
00000000-0000-0000-0000-000000000001. - Prefer re-invoking the failed service’s
purgeover manual SQL deletes. - Document reason and ticket in
company_delete_jobs.reasonwhen forcing. - After manual recovery, set job
status=completedandcompleted_at=now()only if Core company row is already deleted and all previews are empty.
7.4 Verification checklist
Section titled “7.4 Verification checklist”-
GETCore delete-preview → 404 oralreadyPurged - Auth/Finance/Promoters/Artists/Venues previews → zero owned rows
- No active sessions for former members (Auth)
- S3 prefixes empty for company UUID
- Company absent from Admin companies list
8. Related docs
Section titled “8. Related docs”- Backend Admin API — company control-plane routes
- Frontend Admin — company detail hard-delete UI
- Core — commercial master SoT
- Per-service purge sections: Finance API, Artists endpoint map, Venue API, Promoters integration
9. Distinction: self-serve retire (Promoters)
Section titled “9. Distinction: self-serve retire (Promoters)”| Platform hard-delete (this doc) | Promoters self-serve retire | |
|---|---|---|
| Actor | Platform staff (Admin) | TENANT_SUPERADMIN |
| Scope | Full cross-service purge | Empty package shell; BFF orchestrates Auth + Core inactive + Mongo |
| API | /api/v1/admin/companies/.../delete | GET|DELETE /api/users/companies/:id |
| Core end state | Row removed | status: inactive |