00 Context — why this exists
A VAR partner may soon ask us to delete its customers' data. Tim is preparing lists of the affected companies and users and, based on subscription expiration, when each would be deleted. Leadership asked the group what deleting a user and a company record means: technically, for the user experience, for people with personal portfolios, for re-subscription and certifications, for export, and legally.
This page answers those questions from the code, lays out the two ways we could do it (hard delete and anonymize), and lists the questions that have to be answered before anyone deletes anything.
Statements about the codebase are verified against source (platform-backend, schema dump in src/database/schema/mysql-schema.sql). Legal and regulatory points are directional only and need Legal sign-off. No code, data or tickets were changed.
01 TL;DR & recommendation
Deleting a user fails on 13 database tables that block it, deleting a company only hides it, and neither one touches Cognito, Stripe, ChurnZero or Segment.
Whether a VAR can tell us to delete its customers' data depends on the contract and on who controls the data. That answer shapes everything else.
Scrub personal data, delete uploaded content and outside accounts, and keep the anonymous rows. This meets the erasure standard without breaking the database or reports.
Either path needs new actions, a dry-run report on Tim's list, an audit log and outside-system cleanup. Plan a spike before promising a date.
02 Open questions · authority & ownership blocks everything
"Delete the VAR's customer data" assumes the VAR has the right to ask and that we know what counts as "their" data. Neither is settled. If we get these wrong we either fail the VAR or delete data someone else had a right to.
Authority: can they even ask us to delete it?
Under GDPR-style rules the controller decides deletion and the processor acts on its instructions. If the end company is the controller and the VAR is just a reseller, a VAR request alone may not be a valid instruction. We may need the end company's sign-off.
Look for "delete", "return", "delete or return", and retention periods. Many contracts let the customer choose between return and deletion, and some require deletion within N days of expiry whether or not anyone asks.
"Their data" might mean VAR orders, reseller users and pricing, not the learning data of thousands of end users. We should get the request in writing and pin down its scope.
Regulators accept proper anonymization as erasure. A contract can still be stricter, so this affects which path we take.
Ownership: whose data is it?
Portfolio users can add a personal email (users.solid_career_email) separate from their work login, and build a public portfolio. A certificate earned by a person arguably belongs to that person, not their employer's reseller. Deleting the person might destroy data the VAR has no claim to.
One users row can belong to several accounts and companies through account_users and company_users. The safe default: remove the VAR company's memberships, and only erase the person if that company is the only thing they belong to.
It's personal data about the user, collected because the company paid for the seat. The answer decides whether a company-level request reaches it.
Obligations & process
GDPR (portability), CCPA, PIPEDA for Canadian users, and FERPA if any customers are schools all bear on this. Separately, it's good practice to let users download their certificates.
Invoices, orders and tax records usually have required retention periods, which are a legitimate exception to deletion. Confirm which tables fall under them.
Deletion won't reach RDS snapshots. The standard answer is "deleted from production now, purged from backups as they expire within N days". We need the real N.
Tim's lists suggest expiry-based timing. A grace period (e.g. 30–90 days) protects against re-subscription regret.
03 Current state · the code platform-backend (Laravel)
Deleting a user: ForceDeleteUser
src/app/Users/Actions/ForceDeleteUser.php, reached from UserController::destroy and from php artisan user:force-delete {uuid}. One user at a time.
- Refuses to delete anyone who is a sales or CS rep on an account.
- Deletes the user's company, school, group and class memberships, and clears
created_by_user_idon groups, classes and assignments. - Renames the individual account to "… deleted at request of user", but doesn't scrub its other fields.
- Then
forceDelete()s the user, and the 25 tables with cascading FKs go with it (achievements, logins, lesson views, bookmarks, phones, addresses, GDPR consent…). - Writes the deleted user's email into the application log. That leaves personal data in the logs after an erasure.
13 foreign keys on users have no delete rule, so the delete is blocked if the user has any row in them: solid_career_profiles, storyline_experiences, user_events (user_id + dispatched_by), user_course_version_bookmarks, splt_users, enrollment_keys.created_by_id, *_events.dispatched_by, and the rep columns on accounts/orders. Any active learner with Storyline progress or a portfolio hits an FK error and the transaction rolls back.
Deleting a company: CompanyController::destroy
Deletes the company's notes, soft-deletes the company and fires CompanyWasDestroyed to Segment. Users, memberships, groups, addresses and phones are left untouched. Every FK on companies blocks deletes, so a hard delete would need every child removed first.
Outside our database
| System | What happens on delete today |
|---|---|
| Cognito | Not touched The login identity survives. The user can probably still sign in and land on an empty or broken app. |
| Segment → Amplitude | Not deleted UserWasDestroyedListener sends one more identify + track. Nobody files a Segment deletion/suppression request. |
| Stripe, ChurnZero, SendGrid | Not touched Customer, contact and mailing records remain. |
| Typesense (Scout) | Handled User is Searchable, so Scout's model observer removes the document on delete. Portfolio indexes (scout-sc-portfolios) need checking. |
| S3 | Not touched Portfolio gallery images, project files and avatars stay in the bucket. |
| Legacy SQL Server | Separate tool legacy:delete-customer {idcustomer} runs raw SQL against legacy. It isn't connected to the platform delete. |
| Application logs | Leaks The deleted user's email is logged by ForceDeleteUser. |
| RDS backups | By retention Data stays until snapshots expire. |
Other things worth knowing
- Orphans with no FK:
sv_user_skill_historyandsv_user_skill_metricslink bysubject_uuid;skills_analyzer_test_attemptsstores a rawemail. - No "delete consequences" logic: the consequence text on
User/Accountis still placeholder ("Foo", User.php:135-144). - No anonymize or "forget me" flow exists. The "GDPR" code (
gdpr_user,/users/{uuid}/gdpr) only records policy consent.
04 Answers to the thread
Today, a bulk hard delete would mostly fail on the blocking FKs. Where it succeeds it leaves orphaned skill-validation rows, S3 files, and records in Cognito, Stripe, ChurnZero and Segment. Company delete only hides the company. Backups keep everything until they expire. Doing this correctly means writing new code (see sections 05 and 06).
Their Cognito identity still exists, so they can likely authenticate and then hit a missing platform user. That's a broken or confusing experience untested. With proper cleanup they'd see a normal "no account" login failure and could register fresh, starting from zero.
Their personal email (solid_career_email), portfolio, projects, galleries and external certificates are all tied to the user and disappear with them. This is the strongest argument for removing the company membership rather than the person when someone has a personal portfolio, pending Legal's view on ownership (section 02).
Correct after a hard delete. Backups aren't a practical restore path for individual companies. After anonymization the company's structure (account, groups, seats) could be revived, but not who the users were. A grace period before deletion is the real protection.
Yes. user_achievements cascades on user delete, and any shared certificate or verification links stop working. Offering a certificate download beforehand avoids most of the harm.
Partly. There's a VAR company-user CSV export (routes/v1/var/index.php, ExportCompanyUsers) and admin course view-statistics exports. There's no full per-user export covering progress, certificates and portfolio. That would mean new code or manual SQL.
Possibly. It depends on the contract, the jurisdiction and who the controller is. It's a question for Legal (section 02), not engineering.
Authority to delete; users shared with other companies; invoice and tax retention; backups; personal data in logs; outside systems; written proof of deletion; and the CS/support impact if a user calls after we've deleted them. All of these are covered above.
05 Path A · Hard delete
Physically remove the company, its memberships, and every user who belongs only to that company, along with their dependent rows, files and outside accounts.
- A
HardDeleteCompanyaction that walks the FK graph in dependency order and handles the 13 blocking tables explicitly. - Decisions for rows other people reference: orders, enrollment keys, assignments, events written by this user.
- S3 cleanup for portfolio and avatar files.
- Deletion in Cognito, Stripe, ChurnZero and SendGrid, plus a Segment deletion request.
- Dry-run mode, batching, an audit log, and removing email from the log line.
- + Unambiguous: the data is gone, and it meets a contract that says "delete".
- + Smallest legal footprint going forward.
- − Irreversible; no re-subscribe recovery.
- − Breaks history: revenue, VAR order history and aggregate usage reports lose rows.
- − Highest risk: a wrong order or a missed table means a failed transaction or orphaned data, at bulk scale.
- − Must keep invoices and orders anyway, so it's never a pure "delete everything".
06 Path B · Anonymize recommended
Keep the rows and destroy the identity. Overwrite every personal field, delete free-text and uploaded content outright, delete the outside accounts, and leave anonymous records so the database and reports stay intact.
users: name, login email →deleted-<uuid>@invalid,solid_career_email(+pending), password, SSO links.- Phones, addresses, login IPs,
skills_analyzer_test_attempts.email. - Company: name, addresses, phones, domains. A company name next to a few users' history is enough to re-identify them.
- Portfolio profiles, projects, galleries, external certs, S3 files, avatars.
- Free text that may contain PII: survey answers, notes, bios.
- Cognito user, ChurnZero contact, SendGrid contact, Stripe customer (unless tax-held); Segment deletion request.
- Lesson views, completions, assessment scores, linked only to an anonymous ID.
- Orders, VAR orders, invoices (also covers tax retention).
- Group and seat structure, for reporting and possible re-subscription.
- + No FK breakage: almost nothing is physically deleted.
- + Reports, revenue and VAR history stay accurate.
- + Counts as erasure under GDPR if it's real anonymization (see below).
- + Safer to build: one idempotent action, easy dry run.
- − A contract that literally says "delete" may not accept it.
- − Easy to miss a field, so it needs a maintained PII-column inventory.
If we keep a mapping table, or leave anything that lets someone work back to the person (a unique job title, a tiny company's learning history next to its name), it's only pseudonymized and still counts as personal data. No lookup table, no "undo", and scrub the company too.
07 Side-by-side
| Dimension | A · Hard delete | B · Anonymize |
|---|---|---|
| Personal data removed | Yes | Yes, if done thoroughly |
| Meets "delete" in a contract | Yes | Usually; Legal must confirm |
| Database integrity | Needs FK-ordered teardown; high risk | Rows stay; low risk |
| Reporting & revenue history | Rows lost | Kept, anonymous |
| Re-subscribe recovery | None | Structure only, no identities |
| Certs & portfolios | Lost | Lost (they're personal) |
| Outside-system cleanup | Required | Required |
| Backups | Purge as they expire | Purge as they expire |
| Build effort | Higher | Moderate |
| Reversible | No | No (for identities) |
Either way, outside-system cleanup, the audit log, the dry run and the export offer are the same work. The real choice is only what happens to the internal rows.
08 Data inventory
A first pass at what a company deletion has to touch. It should be checked against the schema during the spike.
| Domain | Tables / systems | Hard delete | Anonymize |
|---|---|---|---|
| Identity | users, phones, addresses, passwords, user_logins, Cognito | Delete | Scrub row; delete Cognito |
| Membership | account_users, company_users, group users, enrollment keys | Delete | Remove VAR company's memberships |
| Company | companies, company addresses/phones, company_groups, notes | Delete (in order) | Scrub; keep structure |
| Learning | lesson views/events/bookmarks, storyline_experiences, user_course_version_bookmarks, survey responses | Delete | Keep; delete free text |
| Achievements | user_achievements → technical_certificates | Delete | Delete (personal) |
| Portfolio | solid_career_*, Modules/Portfolio, S3 files | Delete + S3 | Delete + S3 |
| Skills validation | sv_* (by subject_uuid), skills_analyzer_test_attempts | Delete (no FK, manual) | Keep scores; scrub email |
| Commerce | orders, var_orders, Stripe | Retain (tax) | Retain (tax) |
| Events / audit | user_events, *_events.dispatched_by | Delete or re-point | Keep, anonymous |
| Outside | Segment→Amplitude, ChurnZero, SendGrid, Typesense, legacy SQL Server, logs | Same for both: deletion requests and suppression; stop logging email | |
09 Proposed plan
Section 02: authority, ownership, export obligation, retention exceptions, backup wording, proof and SLA. Get the VAR's request in writing with its scope. Blocks everything.
Hard delete vs anonymize (recommend anonymize), the rule for shared and portfolio users, and the grace period.
Read-only: per company, the user count, users shared with other companies, users with portfolios or certs, and rows per table. Shows the real scope before we build anything.
AnonymizeUser / AnonymizeCompany actions (or the hard-delete versions), idempotent and batched. Outside-system cleanup jobs, an audit log table (what, when, who requested it, no PII), and remove the email from the log line. Tested on the migration environment first, never shared staging.
If Legal requires it: company CSV (existing VAR export) plus certificate downloads for users, and a notice window.
Run per company on its scheduled date and send the VAR a deletion certificate generated from the audit log, including the backup-expiry statement.
10 How to verify these findings
- User delete behavior: read
platform-backend/src/app/Users/Actions/ForceDeleteUser.php. - Blocking FKs:
grep 'REFERENCES `users`' src/database/schema/mysql-schema.sql | grep -v "CASCADE\|SET NULL"(13 results; 25 of 39 cascade). - Company FKs:
grep 'REFERENCES `companies`' src/database/schema/mysql-schema.sql(4 results, no delete rule). - Company delete:
src/app/Interfaces/Api/v1/Commercial/Controllers/CompanyController.php:102-117. - Segment on destroy:
src/app/Users/Listeners/UserWasDestroyedListener.php. - Outside services configured:
src/config/services.php. - Real-world blast radius: run the Step 2 counts read-only against prod via
db-connectfor one company on Tim's list.