VAR · DATA DELETION · PROPOSAL

Deleting a VAR Partner's Customer Data

Status Draft, needs Legal  ·  Stream VAR + Platform Access
Type Eng proposal  ·  Dated 2026-10-20

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.

How to read this document

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

TODAY
No safe bulk delete exists

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.

FIRST
Legal decides whether we may

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.

RECOMMEND
Anonymize, don't hard delete

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.

EFFORT
A small project, not a script

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

Answer these before building or promising anything

"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?

Who is the data controller: the VAR, the end company, or SolidProfessor? Legal

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.

What do the VAR agreement and any DPA actually say about data on termination? Legal

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.

Does the VAR's request cover its customers' users, or only the VAR's own records? Business

"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.

Does anonymization satisfy the request, or does the contract require literal deletion? Legal

Regulators accept proper anonymization as erasure. A contract can still be stricter, so this affects which path we take.

Ownership: whose data is it?

Do individual users own their portfolios, certificates and achievements? Legal Product

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.

What about users who existed before the VAR seat, or also belong to another company? Eng Product

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.

Is a user's learning history (views, completions, assessments) company data or personal data? Legal

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

Must we offer the company or the users an export before deleting? Legal

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.

Do we notify affected end users? Who sends the notice, and what does it say? Legal CS
What must we keep? Legal Finance

Invoices, orders and tax records usually have required retention periods, which are a legitimate exception to deletion. Confirm which tables fall under them.

What do we say about backups? DevOps Legal

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.

Does the VAR need proof (a certificate of deletion), and by when (SLA)? Business
When does each company get deleted: on request, at subscription expiry, or after a grace period? Business

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_id on 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.
It will fail for most real users

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

SystemWhat happens on delete today
CognitoNot touched The login identity survives. The user can probably still sign in and land on an empty or broken app.
Segment → AmplitudeNot deleted UserWasDestroyedListener sends one more identify + track. Nobody files a Segment deletion/suppression request.
Stripe, ChurnZero, SendGridNot 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.
S3Not touched Portfolio gallery images, project files and avatars stay in the bucket.
Legacy SQL ServerSeparate tool legacy:delete-customer {idcustomer} runs raw SQL against legacy. It isn't connected to the platform delete.
Application logsLeaks The deleted user's email is logged by ForceDeleteUser.
RDS backupsBy retention Data stays until snapshots expire.

Other things worth knowing

  • Orphans with no FK: sv_user_skill_history and sv_user_skill_metrics link by subject_uuid; skills_analyzer_test_attempts stores a raw email.
  • No "delete consequences" logic: the consequence text on User/Account is 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

What is the technical impact? (integrity, backups, orphans)

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).

What does the user see if they come back after deletion?

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.

What about people who gave a personal email, e.g. for a portfolio?

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).

If the company re-subscribes, there's no recovery, right?

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.

Tech certs and other achievements are lost, right?

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.

Can we export a company's or a user's data?

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.

Do we have a legal obligation to give them their data first?

Possibly. It depends on the contract, the jurisdiction and who the controller is. It's a question for Legal (section 02), not engineering.

Anything else we're not thinking of?

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.

WHAT IT TAKES
Build
  • A HardDeleteCompany action 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.
TRADE-OFFS
Pros / cons
  • + 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.

SCRUB (overwrite)
Personal fields
  • 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.
DELETE OUTRIGHT
Content & outside identities
  • 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.
KEEP (anonymous)
What survives
  • 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.
TRADE-OFFS
Pros / cons
  • + 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.
The bar: anonymization, not pseudonymization

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

DimensionA · Hard deleteB · Anonymize
Personal data removedYesYes, if done thoroughly
Meets "delete" in a contractYesUsually; Legal must confirm
Database integrityNeeds FK-ordered teardown; high riskRows stay; low risk
Reporting & revenue historyRows lostKept, anonymous
Re-subscribe recoveryNoneStructure only, no identities
Certs & portfoliosLostLost (they're personal)
Outside-system cleanupRequiredRequired
BackupsPurge as they expirePurge as they expire
Build effortHigherModerate
ReversibleNoNo (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.

DomainTables / systemsHard deleteAnonymize
Identityusers, phones, addresses, passwords, user_logins, CognitoDeleteScrub row; delete Cognito
Membershipaccount_users, company_users, group users, enrollment keysDeleteRemove VAR company's memberships
Companycompanies, company addresses/phones, company_groups, notesDelete (in order)Scrub; keep structure
Learninglesson views/events/bookmarks, storyline_experiences, user_course_version_bookmarks, survey responsesDeleteKeep; delete free text
Achievementsuser_achievements → technical_certificatesDeleteDelete (personal)
Portfoliosolid_career_*, Modules/Portfolio, S3 filesDelete + S3Delete + S3
Skills validationsv_* (by subject_uuid), skills_analyzer_test_attemptsDelete (no FK, manual)Keep scores; scrub email
Commerceorders, var_orders, StripeRetain (tax)Retain (tax)
Events / audituser_events, *_events.dispatched_byDelete or re-pointKeep, anonymous
OutsideSegment→Amplitude, ChurnZero, SendGrid, Typesense, legacy SQL Server, logsSame for both: deletion requests and suppression; stop logging email

09 Proposed plan

Step 0
Legal & business answers

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.

Step 1
Decide the path

Hard delete vs anonymize (recommend anonymize), the rule for shared and portfolio users, and the grace period.

Step 2
Spike: dry-run report on Tim's list

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.

Step 3
Build

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.

Step 4
Export offer & notice

If Legal requires it: company CSV (existing VAR export) plus certificate downloads for users, and a notice window.

Step 5
Execute & certify

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-connect for one company on Tim's list.