Skip to content

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.


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-AdminBackend-Kisum-Admin (/api/v1/admin/companies/{companyId}/…). Browsers must never call Auth/Core/persona internal routes directly.


FieldValue
Canonical UUID00000000-0000-0000-0000-000000000001
Core legal_name / display_name[Deleted organization]
Core statusarchived
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 tombstoneCompanyId with a strict version/variant regex — either use a lenient parser (Go uuid.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=true on 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.


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
StatusMeaning
pendingJob created; company locked deleting; worker not started or queued
runningAt least one purge step in progress (steps_json updated)
completedAll steps succeeded; company row gone from Core
failedStep error recorded in error_message; ops intervention required
  1. Checkout — cancel Xendit recurring plan/mandate; clear Redis provision progress.
  2. Promoters, Finance, Artists, Venues — parallel POST purge.
  3. Admin — company-scoped S3 prefixes (companies/{coreCompanyId}/).
  4. Auth — memberships, invitations, sessions; orphan-only user hard-delete (mode: full).
  5. CoreDELETE /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.

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.

  • Job survival: company_delete_jobs has no FK to companies — job rows are the permanent audit trail and survive the final purge.
  • Single active job: at most one pending/running job per company (partial unique index). POST …/delete-jobs on a company with an active job returns 409 delete_job_active with the existing jobId + status in the error payload; race-condition creates hit the index and return the same 409 shape. Job creation is atomic (blocker check + insert + deleting lock in one transaction).
  • Mutation freeze: while status = 'deleting', Core rejects company mutations with 409 company_deleting (PATCH company — including moving status off deleting — basic/addon subscription upserts, business-unit create, profile, addresses, documents, activate-signup). Delete-lifecycle routes keep working.
  • Guarded final DELETE: DELETE /internal/companies/{companyId} requires status = 'deleting' and an existing delete job, else 409 delete_not_locked. alreadyPurged: true is 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 plain 404.
  • Job state machine: steps/complete/fail are accepted only from pending/running (terminal jobs → 409); fail clears completedAt; every job write refreshes updatedAt. steps/fail bodies 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_active is forwarded to the browser as 409 delete_job_exists (+ jobId/status) so the UI attaches to the running job. Resume is free for failed/pending; a running job can be resumed only when Core updatedAt is >90s stale (dead-runner lease), else 409 delete_job_running. Resume always uses the job’s stored forceDelete.
  • effectiveStatus: "stalled": Core reports running/pending, no local runner exists, and updatedAt is >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_configured blockers so preview and pipeline agree.
  • S3 purge: company uploads keyed by full UUID; purge sweeps companies/<uuid>/ plus the legacy companies/<first-8>/ prefix with batched DeleteObjects (≤1000/batch) failing on per-object errors.
  • Caching: delete-status/delete-preview are 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.

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.

ServiceOwnershipWhat is counted / purgedShared / blockersMachine routes
CoreOwnedcompanies (final step), company_profiles, company_addresses, company_social_links, company_documents, company_subscriptions, company_addons, company_entitlement_versions, entitlement_history, business_unitsBlocker: 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)
AuthOwnedcompany_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 txOrphan-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
PromotersOwnedMongo 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-onlyIdempotent via company_tenant_purges marker (force bypasses)GET/POST /internal/companies/:companyId/delete-preview|purge
FinanceOwnedPrisma: 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 deletedGET/POST /api/internal/companies/{id}/delete-preview|purge
ArtistsOwned + SharedCore-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_claimsapproved 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 listingsBlockers: shared booking rows. force repoints shared side to tombstone. Preview includes operatorIdsGET/POST /internal/companies/:companyId/delete-preview|purge; optional POST /internal/middle-agent-operators/:orgID/deactivate|activate
VenuesOwned + SharedAll 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 → 400GET/POST /internal/companies/:companyId/delete-preview|purge
CheckoutBilling handoffRedis 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_ACTIONBlocker: 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 purgesGET/POST /internal/companies/{companyId}/delete-preview|cancel-billing
AdminBlocker / storageNo company master SoT; may hold S3 company logos/documents uploaded via Admin file flowsPurge object prefixes when orchestratedBrowser routes + orchestrator worker
MusicDataNoneAnalyst/chart data keyed by artist IDs, not Core company UUIDNot in company purge matrix
RAG / MCP / ChatAudit noteConversation memory and tool audit logs may reference x-org / company UUID in metadataNot 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 masterNo dedicated purge API (2026-07-08)

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 patternOwnerAction on purge
Exact keys parsed from purged docs’ URLsPromoters S3 (images bucket)Delete listed objects (primary)
companies/{coreCompanyId}/Promoters S3Extra 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 keysAdmin S3Delete per Admin orchestration step

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.

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": [] }
}
}

Body:

{
"force": true,
"reason": "Customer requested GDPR erasure — ticket #1234",
"confirmName": "Acme Promoters Ltd",
"resumeJobId": "optional-uuid-to-continue-after-failure"
}

Validation:

  • confirmName required; must match Core legalName or displayName (case-insensitive).
  • Non-forceOk blockers → 409 delete_blocked unless force: 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)”
ServicePreviewPurge / delete
CoreGET /internal/companies/{companyId}/delete-previewPOST …/delete-jobs, GET …/delete-jobs/{jobId}, DELETE …/{companyId}
AuthGET /internal/companies/{companyId}/delete-previewPOST …/purge body { "mode": "full", "force?", "tombstoneCompanyId?" }
PromotersGET /internal/companies/:companyId/delete-previewPOST …/purge same body shape
FinanceGET /api/internal/companies/{companyId}/delete-previewPOST …/purge
ArtistsGET /internal/companies/:companyId/delete-previewPOST …/purge
VenuesGET /internal/companies/:companyId/delete-previewPOST …/purge
CheckoutGET /internal/companies/{companyId}/delete-previewPOST …/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".


  1. 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.
  2. Tombstone is read-only — No memberships, subscriptions, or login for tombstone; purge APIs reject it.
  3. Force is explicit — Operators must enable force in Admin UI and provide a reason when overriding forceOk blockers (e.g. active billing).
  4. Hard blockers — Blockers without forceOk (e.g. non-force shared graph conflicts, tombstone) cannot be overridden.
  5. Idempotency — Re-running purge on an already-purged service returns alreadyPurged / idempotent success; orchestrator should mark the step complete and continue.
  6. Canonical ID — All machine calls use Core company UUID (x-org canonical), not legacy Mongo _id. Promoters resolves legacy id internally for Mongo + S3.

  1. 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;
  2. Inspect steps_json for last completed step key and error_message.
  3. Confirm company row: SELECT id, status FROM companies WHERE id = '<uuid>'; (deleting = mid-flight).
SymptomLikely causeAction
Job failed on promotersMongo/S3 timeout or missing internal keyFix creds/network; POST Promoters purge again with same companyId; update job step manually or restart job from Admin
Job failed on financeActive Xero syncComplete/cancel Xero pipeline or re-run with force=true after ops approval
Job failed on artists / venuesShared booking/event without forceRe-run preview; if expected, restart delete with force=true
Job running > 30 minWorker crashCheck Admin/Core logs; resume purge from failed step (idempotent services)
Company stuck deleting, no jobPartial createInsert/repair job row or set company inactive after manual verification — escalate before manual SQL
Company gone but orphan data in one serviceStep skippedRun that service’s POST purge with machine key; verify counts in delete-preview = 0
  • Never delete tombstone 00000000-0000-0000-0000-000000000001.
  • Prefer re-invoking the failed service’s purge over manual SQL deletes.
  • Document reason and ticket in company_delete_jobs.reason when forcing.
  • After manual recovery, set job status=completed and completed_at=now() only if Core company row is already deleted and all previews are empty.
  • GET Core delete-preview → 404 or alreadyPurged
  • 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


9. Distinction: self-serve retire (Promoters)

Section titled “9. Distinction: self-serve retire (Promoters)”
Platform hard-delete (this doc)Promoters self-serve retire
ActorPlatform staff (Admin)TENANT_SUPERADMIN
ScopeFull cross-service purgeEmpty package shell; BFF orchestrates Auth + Core inactive + Mongo
API/api/v1/admin/companies/.../deleteGET|DELETE /api/users/companies/:id
Core end stateRow removedstatus: inactive

See Promoters API § Profile companies.