Skip to content

feat(admin): finish Cloud GDPR deletion catalog v2 - #5397

Open
eshurakov wants to merge 4 commits into
mainfrom
eshurakov/playful-island
Open

feat(admin): finish Cloud GDPR deletion catalog v2#5397
eshurakov wants to merge 4 commits into
mainfrom
eshurakov/playful-island

Conversation

@eshurakov

Copy link
Copy Markdown
Contributor

Summary

Move remaining CSA-owned GDPR deletion work onto the Cloud admin queue.

  • Freeze catalog v1; new requests use catalog v2 (pylon_finalize, csa_support_db).
  • Ticket-only intake: customer email resolves at preflight from the Pylon ticket.
  • Pylon reply, tag delete-complete, close the ticket, then delete the contact.
  • Cloud calls CSA to scrub the support DB after the Pylon contact is gone.
  • PostHog uses environment APIs with a 3-attempt verify cap, then succeeds unconfirmed.
  • Local deletion-mock covers PostHog, Pylon, Substack, Customer.io, and CSA.

Verification

  • pnpm format:changed and git diff --check
  • oxlint on changed GDPR/deletion-mock files: clean
  • pnpm --filter web exec tsgo --noEmit and pnpm --filter @kilocode/db exec tsgo --noEmit
  • Jest (161/161) for changed deletion-queue, router, and GDPR-removal tests
  • deletion-mock + services tests: 46/46
  • Manual local E2E against deletion-mock: happy path #1001 completed 12/12; 429/5xx/expired-Substack/missing-user/delete_ready_missing parked as designed

Visual Changes

Admin deletion queue intake is visible. New v2 steps: Pylon finalize and CSA support DB.

@eshurakov
eshurakov force-pushed the eshurakov/playful-island branch from 47e46ee to 8e479a5 Compare August 20, 2026 11:33
Move remaining CSA-owned deletion work onto the Cloud queue: ticket-only intake, Pylon finalize, and CSA support-db scrub. Freeze catalog v1 and add v2 steps so new requests run the full pipeline.

Ticket email now resolves at preflight. PostHog uses environment APIs with a 3-attempt verify cap. Local deletion-mock covers PostHog, Pylon, Substack, Customer.io, and CSA.
@eshurakov
eshurakov force-pushed the eshurakov/playful-island branch from 8e479a5 to ef2baa4 Compare August 20, 2026 11:48
@kilo-code-bot

kilo-code-bot Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Files Reviewed (3 files)
  • apps/web/src/app/admin/deletion-queue/[id]/DeletionQueueDetailContent.tsx
  • apps/web/src/app/admin/deletion-queue/deletion-queue-format.test.ts
  • apps/web/src/app/admin/deletion-queue/deletion-queue-format.ts
Previous Review Summaries (2 snapshots, latest commit 93eebb4)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit 93eebb4)

Status: No Issues Found | Recommendation: Merge

Files Reviewed (5 files)
  • apps/web/src/lib/user/deletion-queue/deletion-intake.test.ts
  • apps/web/src/lib/user/deletion-queue/deletion-intake.ts
  • apps/web/src/lib/user/deletion-queue/deletion-preflight.ts
  • apps/web/src/lib/user/deletion-queue/deletion-ticket-resolve.test.ts
  • apps/web/src/lib/user/deletion-queue/handlers/pylon-reply.ts

Previous review (commit ef2baa4)

Status: 1 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 0
Issue Details (click to expand)

WARNING

File Line Issue
apps/web/src/lib/user/deletion-queue/deletion-intake.ts 96 isInternalOrRelayEmail uses endsWith('@service.usepylon.com'), so user@mail.service.usepylon.com is treated as a customer requester and ticket-only preflight never walks the thread
Files Reviewed (56 files)
  • ENVIRONMENT.md
  • apps/web/src/app/admin/api/users/gdpr-removal/route.test.ts
  • apps/web/src/app/admin/api/users/gdpr-removal/route.ts
  • apps/web/src/app/admin/components/UserAdmin/UserAdminGdprRemoval.tsx
  • apps/web/src/app/admin/deletion-queue/DeletionQueueContent.tsx
  • apps/web/src/app/admin/deletion-queue/[id]/DeletionQueueDetailContent.tsx
  • apps/web/src/app/admin/deletion-queue/deletion-queue-format.ts
  • apps/web/src/app/api/internal/support/users/gdpr-removal/route.test.ts
  • apps/web/src/app/api/internal/support/users/gdpr-removal/route.ts
  • apps/web/src/lib/user/deletion-queue/deletion-catalog.test.ts
  • apps/web/src/lib/user/deletion-queue/deletion-catalog.ts
  • apps/web/src/lib/user/deletion-queue/deletion-completion.test.ts
  • apps/web/src/lib/user/deletion-queue/deletion-completion.ts
  • apps/web/src/lib/user/deletion-queue/deletion-constants.ts
  • apps/web/src/lib/user/deletion-queue/deletion-enqueue.test.ts
  • apps/web/src/lib/user/deletion-queue/deletion-enqueue.ts
  • apps/web/src/lib/user/deletion-queue/deletion-hints.test.ts
  • apps/web/src/lib/user/deletion-queue/deletion-hints.ts
  • apps/web/src/lib/user/deletion-queue/deletion-hmac.ts
  • apps/web/src/lib/user/deletion-queue/deletion-intake.test.ts
  • apps/web/src/lib/user/deletion-queue/deletion-intake.ts - 1 issue
  • apps/web/src/lib/user/deletion-queue/deletion-outcomes.ts
  • apps/web/src/lib/user/deletion-queue/deletion-preflight.test.ts
  • apps/web/src/lib/user/deletion-queue/deletion-preflight.ts
  • apps/web/src/lib/user/deletion-queue/deletion-preview.test.ts
  • apps/web/src/lib/user/deletion-queue/deletion-preview.ts
  • apps/web/src/lib/user/deletion-queue/deletion-ticket-resolve.test.ts
  • apps/web/src/lib/user/deletion-queue/deletion-ticket-resolve.ts
  • apps/web/src/lib/user/deletion-queue/handlers/csa-support-db.test.ts
  • apps/web/src/lib/user/deletion-queue/handlers/csa-support-db.ts
  • apps/web/src/lib/user/deletion-queue/handlers/index.ts
  • apps/web/src/lib/user/deletion-queue/handlers/posthog.test.ts
  • apps/web/src/lib/user/deletion-queue/handlers/posthog.ts
  • apps/web/src/lib/user/deletion-queue/handlers/pylon-client.test.ts
  • apps/web/src/lib/user/deletion-queue/handlers/pylon-client.ts
  • apps/web/src/lib/user/deletion-queue/handlers/pylon-contact.test.ts
  • apps/web/src/lib/user/deletion-queue/handlers/pylon-contact.ts
  • apps/web/src/lib/user/deletion-queue/handlers/pylon-finalize.test.ts
  • apps/web/src/lib/user/deletion-queue/handlers/pylon-finalize.ts
  • apps/web/src/lib/user/deletion-queue/handlers/pylon-reply.test.ts
  • apps/web/src/lib/user/deletion-queue/handlers/pylon-reply.ts
  • apps/web/src/lib/user/deletion-queue/handlers/substack.test.ts
  • apps/web/src/lib/user/deletion-queue/handlers/substack.ts
  • apps/web/src/routers/admin/user-deletion-queue-router.test.ts
  • apps/web/src/routers/admin/user-deletion-queue-router.ts
  • apps/web/src/tests/cli-config-schema.test.ts
  • dev/local/scripts/deletion-provider-mock.test.ts
  • dev/local/scripts/deletion-provider-mock.ts
  • dev/local/services.test.ts
  • dev/local/services.ts
  • packages/db/src/migrations/0222_military_war_machine.sql
  • packages/db/src/migrations/meta/0222_snapshot.json
  • packages/db/src/migrations/meta/_journal.json
  • packages/db/src/schema-types.ts
  • packages/db/src/schema.test.ts
  • packages/db/src/schema.ts

Fix these issues in Kilo Cloud


Reviewed by grok-4.6 · Input: 137.5K · Output: 7.6K · Cached: 350.7K

Review guidance: REVIEW.md from base branch main

Ticket preflight used endsWith('@service.usepylon.com'), so a requester
like user@mail.service.usepylon.com was treated as the customer and the
thread was never walked. Match the existing includes() check used by
pylon-reply and drop the duplicate refused-resolved helper.
Keep preflight request-level, not a catalog step, and lock cleanup until promotion.

@intentionally-left-nil intentionally-left-nil left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I only got as far along as pylon-client

const [result] = await enqueueUserDeletionTargets({
actor: { kiloUserId: null, email: actorEmail },
targets: [{ email: user.google_user_email, trustedUserId: user.id }],
catalogVersion: 1,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do catalogVersions mean in this context? Should this be 1 or 2?

@eshurakov eshurakov Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

v1 is the frozen original catalog. v2 is current (USER_DELETION_CATALOG_VERSION) and adds Posthog, Substack, pylon_finalize, and csa_support_db. This CSA support route stays on 1 so CSA-initiated deletes do not run Cloud-owned v2 steps, including calling CSA back. Admin enqueue uses 2.

So currently this flow is split into two parts with one being on CSA (customer support website) and the other in Cloud admin. And I'm moving it all to Cloud admin. In the end after everything is done there will be no catalog version needed.

}

const headers: Record<string, string> = {
Authorization: `Bearer ${secret}`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked, and if deletionFetch fails, there are no errors thrown and the bearer secret ought not to be in logfiles

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. deletionFetch classifies the failure and does not log request headers, so the bearer should not land in logs.

if (stop) return stop;

const emailOrOutcome = requireTargetEmail(request);
if (typeof emailOrOutcome !== 'string') return emailOrOutcome;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without trying to get in and suggest a whole new way of determining codeflows and error handlings - this type of checking is the result of overloading the return type, as opposed to e.g. chaining promises, or other exception types.

My first instinct would have been to check for a generic Outcome type, but since these have no hierarchy, you can't do e.g. an instanceof check.

Anyways, it's not worth redoing this whole code around how requireTargetEmail (and all of these outcome patterns work), but it's one of the first things I noticed

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same union as the other handlers (string | DeletionHandlerOutcome). Not changing that pattern in this PR.

type DeletionHandler,
} from '@/lib/user/deletion-queue/handlers/common';

export const handleCsaSupportDb: DeletionHandler = async ({ request, context }) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that this function returns bare objects, can we specify a return type to catch any future tying errors?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already typed as DeletionHandler, which is Promise<DeletionHandlerOutcome>. The bare objects have to match that.

const propertyEmail = properties ? properties.email : undefined;
if (typeof propertyEmail === 'string' && propertyEmail) emails.add(propertyEmail);
if (typeof person.name === 'string' && person.name.includes('@')) emails.add(person.name);
for (const value of [properties?.email, properties?.$email]) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oof why is there a .email and a .$email

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog reserved person property is $email; some payloads also set email. We collect both.

if (!isRecord(payload) || !Array.isArray(payload.results)) return null;
const persons: PosthogPerson[] = [];
function parsePersons(payload: unknown): ParsePersonsResult {
if (!isRecord(payload) || !Array.isArray(payload.results)) return { kind: 'incomplete' };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this expected? Would we want any kind of error logging to determine what happened?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parsePersons only classifies. lookupPersons maps incomplete to needs_attention / posthog_lookup_incomplete, so it does surface.

if (!isRecord(entry)) continue;
const uuid = typeof entry.uuid === 'string' && entry.uuid.length > 0 ? entry.uuid : null;
if (!uuid) {
missingUuid = true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is another case we could not delete emails if the uuid is a different type. Would it be better to error out here instead?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-string or missing uuid already fails the whole parse as missing_uuidmanual_action_required. The continue is so we still scan the rest of the page before returning that.

await db
.select({ id: kilocode_users.id, blocked_reason: kilocode_users.blocked_reason })
.from(kilocode_users)
.where(eq(sql`lower(${kilocode_users.google_user_email})`, normalizeDeletionEmail(email)))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we normalize the email in any other way besides lowercasing it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trim and lowercase only, same as normalizeDeletionEmail everywhere else. No plus-tag or Gmail-dot folding.

if (!statusPoll.response.ok) return { outcome: classifyPosthogResponse(statusPoll.response) };
const body = await readJsonUnknown(statusPoll.response);
if (!isRecord(body) || !Array.isArray(body.results)) {
return { pending: true };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this case pending, but not an error?

@eshurakov eshurakov Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Malformed or empty deletion_status while PostHog is still processing is treated as not-yet-verified. We retry, then after USER_DELETION_POSTHOG_MAX_VERIFY_ATTEMPTS succeed unconfirmed instead of blocking the queue.

The most important part is that we make sure that deletion request was accepted. Posthog deletion is an async process and we don't want to wait forever (it really takes a while) till they actually delete it, so we try a few times and then mark it as success.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants