Raxx · internal docs

internal · gated

Manual DSR Handling — Standard Operating Procedure

Version: 1.2.0 Last Updated: 2026-10-31 UTC Owner: Operator (Kristerpher) SLA: 45 days from request receipt to response (CPRA/CCPA Cal. Civ. Code § 1798.100(b))

Status: This SOP is ACTIVE as of v1 launch. Automated DSR tooling is deferred to 2026-Q3 (#1630). During the deferral window, this manual process funds the privacy policy commitment at docs/legal/policies/privacy-policy-v1.md Section 8.

As of 2026-10-31 UTC the automated erasure service (billing_dsr_service.execute_dsr) is not live: its routes answer 404 while billing_dsr_erasure_flow is OFF. A request that arrives by email (policy Section 8) is processed by hand with this SOP, or with the Console customer delete. See Section 3.3 Step 4 for the detail.

This document does NOT constitute legal advice. See BLR DIY privacy memo PR #1646 and ADR-0076 §OQ-4.


Cross-references


1. Intake

1.1 How requests arrive

Customers email support@raxx.app to initiate a DSR. Three request types are in scope:

Portability requests ("give me my data in machine-readable format") are treated as a sub-type of Access.

1.2 FreeScout ticket creation

  1. The inbound email creates a FreeScout ticket automatically (via the support@raxx.app mailbox).
  2. If no auto-ticket is created (e.g., the request arrives via another channel), create a ticket manually in FreeScout.
  3. Apply the tag dsr-request to the ticket.
  4. Note the receipt timestamp (UTC) in the ticket body — this is Day 0 of the 45-day SLA.
  5. Acknowledge receipt to the requester within 48 hours:

Subject: We received your privacy request — [Ticket #XXXX]

Hi [Name],

We received your request on [DATE UTC]. We will respond within 45 days. If we need additional information to verify your identity, we will contact you.

Questions? Reply to this email or reference ticket #XXXX.

Raxx Support support@raxx.app


2. Verification

Before processing any DSR, confirm the requester is the account holder.

2.1 Verification levels

Request type Verification required
Access Email match: inbound email matches the account email in the billing_customer table, or, for an account with no billing record, users.email (Section 2.3)
Correction Same as Access
Deletion / anonymization Stronger verification: (a) email match, AND (b) for sensitive deletions, send a one-time verification code to the account email and require the requester to reply with it

2.2 Verification steps

  1. Query billing_customer by email. Confirm the account exists and the email matches. If the query returns nothing, do not conclude there is no account yet: a free-tier account has no billing record. Run the users.email check in Section 2.3 first.
  2. If the requester's email matches neither a billing_customer row nor a Raptor users row (Section 2.3), reply:

    "We could not locate an account associated with this email address. If you have a different email on file, please reply with it. If you believe this is an error, contact us at support@raxx.app."

  3. Document the verification outcome (confirmed / unable to verify) in the FreeScout ticket notes.
  4. For deletion requests: send a confirmation email to the account address:

    "We received a deletion request for the account associated with this email. If you did not submit this request, contact support@raxx.app immediately. If you did submit this request, no action is needed — we will proceed in 48 hours."

2.3 Account with no billing customer row

A free-tier account has a Raptor users row but no billing record: a person who never subscribed has no billing customer row. If that person answered the setup quiz, the account also holds a trader_profiles row and quiz answers in onboarding_state (#4837), and the privacy policy tells them that deleting their account removes those answers. So an empty billing_customer result is not "no account", and the Section 2.2 step 2 reply must not be sent until this check has also come back empty.

Verify against the Raptor users table (read-only):

SELECT id, email, created_at FROM users WHERE lower(email) = lower('<requester_email>');

What to do next for this account, by request type:


3. Process per DSR Type

3.1 Access Request

Scope: Export all personal data Raxx holds for the account, including: - Identity: name, email - Billing: billing address, last-4 card digits, card brand, Stripe customer ID, payment status - Payment event history: late_payment_count, chargeback_count, failed_charge_count - Acquisition source, customer segment - Invoice history (invoice amounts, dates, status) - Account metadata (created_at, plan, account status) - Strategy configuration data (if stored in scope)

Steps:

  1. Run the access export query (see Section 6.1).
  2. Format results as a JSON or CSV file.
  3. Send via secure channel (attach to FreeScout reply, or use a secure file share if volume is large).
  4. Include a plain-language cover note explaining each field.
  5. Log audit row (see Section 4). billing_action_log has no Access action, and an account with no billing customer row has no row to log against; either way the record is the FreeScout ticket and the tracking spreadsheet (Section 4.2).
  6. Mark FreeScout ticket resolved; note completion date.

Deadline: 45 days from receipt (CPRA/CCPA Cal. Civ. Code § 1798.100(b)); extendable by an additional 45 days with notice where reasonably necessary. For reference: GDPR Art. 12(3) sets a 30-day baseline with up to 2-month extension — if EU residents are in scope, the shorter GDPR window is the binding deadline for those requests.


3.2 Correction Request

Scope: Correct inaccurate personal data the customer identifies.

Steps:

  1. Verify the specific data field and the correction requested.
  2. For billing fields (email, name, address): update directly in the billing_customer table.
  3. For payment event counts (late_payment_count etc.): verify the count against Stripe event history (Stripe Dashboard → Customer → Payment history). If a Stripe event was erroneously recorded, update the count and note the discrepancy.
  4. For Stripe-sourced data (card brand, last 4): update in Stripe Dashboard, then sync to billing_customer.
  5. Send confirmation to the requester with the corrected values.
  6. Log audit row (see Section 4). billing_action_log has no Correction action, and an account with no billing customer row has no row to log against; either way the record is the FreeScout ticket and the tracking spreadsheet (Section 4.2).
  7. Mark FreeScout ticket resolved.

Edge case — disputed payment event counts: If the requester disputes a failed_charge_count but Stripe records confirm the event, document the Stripe event ID in the ticket and explain to the requester that the count reflects events recorded by our payment processor. Escalate to the operator if the discrepancy is unresolvable.


3.3 Deletion / Anonymization Request

Scope: Anonymize customer PII on the billing_customer record. Preserve invoice and transaction records for tax compliance (7-year floor). See docs/ops/policies/data-retention.md.

Account with no billing customer row (Section 2.3). There is no billing_customer row to anonymize and there are no invoices, so steps 2, 3 and 5 below do not apply. Take the <raptor_user_id> from Section 2.3, then do ONE of the following:

Then use the variant confirmation in step 6 and skip step 7's billing_action_log row (that table is keyed on a billing customer; there is none). The compliance record for this account is the dsr_log rows from Step 4 (or the Console audit row), the FreeScout ticket and the tracking spreadsheet.

Steps:

  1. Check the decision tree (Section 7) for edge cases before proceeding.
  2. Run the anonymization SQL (see Section 6.2).
  3. Confirm the anonymization completed (query the row; verify anonymized_at IS NOT NULL).
  4. Erase the Raptor-side data for the user account: idempotency_keys (4a/4b) and the stored setup-quiz answers (4c). For anonymize-path erasures the users row is not deleted, so ON DELETE CASCADE does not fire on any table that hangs off it; each of these is an explicit sweep. idempotency_keys: SC-IK-1 (#4145), ADR-0138 §Security/GDPR, issue #4150. Setup answers (trader_profiles, onboarding_state and the advisory personal rule in personal_rules, #4828): #4837 — the privacy policy and the setup quiz notice tell people that deleting their account removes these answers, so this is a promise to keep, not an optional tidy-up.

Automated path (only when billing_dsr_erasure_flow is ON): If the request is processed via billing_dsr_service.execute_dsr(), steps 4a, 4b and 4c all run automatically, in the same transaction as the anonymization: the service calls erase_user_idempotency_keys() and then erase_user_setup_answers() from raptor_user_dsr_service. Do not run the manual SQL below on top of it.

This path is not what runs today. execute_dsr() is reachable only through /api/billing/dsr/* and POST /api/internal/jobs/billing-dsr/execute. Those routes answer 404 unless billing_dsr_erasure_flow is ON (it is default: false, risk: high). Nothing in the repo schedules or calls the batch route, and no application code creates the Raptor billing_customers rows that execute_dsr() works from. So a request that arrives by email (policy Section 8) is processed by hand: the manual path below, or the Console customer delete (see the top of this section). Treat the automated path as available only once the flag is ON and the flow has been exercised end to end; until then do not wait for it. When it has run, verify both compliance rows after the request completes — a dsr_log row for each sweep, whose request_id starts with the DSR id and carries the sweep name:

sql SELECT request_id, tables_affected, rows_pseudonymized, status FROM dsr_log WHERE customer_id = <raptor_user_id> AND (request_id LIKE '%#dsr.idempotency_keys.erased#%' OR request_id LIKE '%#dsr.setup_answers.erased#%') ORDER BY id;

Expect two rows (dsr.idempotency_keys.erased and dsr.setup_answers.erased), both status = 'completed'. A dsr.idempotency_keys.erased row without a dsr.setup_answers.erased row means the setup answers were NOT erased: run Step 4c by hand. (The two sweeps share one transaction, so this only happens if the request was processed by something other than execute_dsr().)

Manual path (operator-executed SQL; the path used today): Look up the Raptor users.id for the account email (Section 2.3 has the query). <ticket_ref> is the FreeScout ticket number; it becomes part of the dsr_log.request_id, which is unique, and the NOW() suffix keeps a re-run from colliding. Each statement below deletes and writes its compliance row in one statement, so the row count is the real count; wrap each in a transaction. dsr_log has the columns request_id, customer_id, requested_at, completed_at, tables_affected (JSON array), rows_pseudonymized (the rows deleted, whatever the column is called) and status. There is no action or context column.

sql BEGIN; -- Step 4a: delete idempotency_keys rows for the subject. -- Step 4b: write the compliance audit row, in the same statement. -- Replace <raptor_user_id> with the integer users.id from the users table. WITH gone AS ( DELETE FROM idempotency_keys WHERE scope_user_id = <raptor_user_id> RETURNING 1 ) INSERT INTO dsr_log (request_id, customer_id, requested_at, completed_at, tables_affected, rows_pseudonymized, status) SELECT '<ticket_ref>#dsr.idempotency_keys.erased#' || NOW()::text, <raptor_user_id>, NOW(), NOW(), '["idempotency_keys"]'::jsonb, (SELECT COUNT(*) FROM gone), 'completed'; COMMIT;

If the account has no Raptor users row (billing-only customer), this step is a no-op — skip it and note in the FreeScout ticket.

Step 4c — setup answers (#4837). Needed on the manual path, which is the path that runs today (the automated path above does it only when it is live). The answers live in trader_profiles and in onboarding_state (the quiz slugs in step_data), and the personal rule the user wrote (a habit, their own number and unit) is in personal_rules; all three are keyed on users.id with ON DELETE CASCADE. The admin hard delete (DELETE /api/internal/customers/<id>) removes them through the cascade. An anonymize-path erasure leaves the users row, so the cascade does not fire. For a deletion request, run this in one transaction. It deletes the rows of all three tables and writes the dsr.setup_answers.erased compliance row with the combined row count, like 4a/4b, so there is no separate bookkeeping; also note the count in the FreeScout ticket:

sql BEGIN; WITH p AS ( DELETE FROM trader_profiles WHERE user_id = <raptor_user_id> RETURNING 1 ), o AS ( DELETE FROM onboarding_state WHERE user_id = <raptor_user_id> RETURNING 1 ), r AS ( DELETE FROM personal_rules WHERE user_id = <raptor_user_id> RETURNING 1 ) INSERT INTO dsr_log (request_id, customer_id, requested_at, completed_at, tables_affected, rows_pseudonymized, status) SELECT '<ticket_ref>#dsr.setup_answers.erased#' || NOW()::text, <raptor_user_id>, NOW(), NOW(), '["trader_profiles","onboarding_state","personal_rules"]'::jsonb, (SELECT COUNT(*) FROM p) + (SELECT COUNT(*) FROM o) + (SELECT COUNT(*) FROM r), 'completed'; COMMIT;

Skip it for a billing-only customer with no Raptor users row. dsr_log is audit data: it is not deleted, and it holds table names and counts, never an answer.

  1. Confirm invoice rows are intact (they must NOT be deleted).
  2. Send confirmation to the requester:

    "We have anonymized the personal information associated with your account. Invoice and transaction records required for tax compliance have been retained as described in our privacy policy. A record of this request is retained for compliance purposes."

For an account with no billing customer row, send instead (Console delete):

"We have deleted your account and the data associated with it, including any setup answers. A record of this request is retained for compliance purposes."

or, if the account was kept and only Step 4 was run:

"We have deleted the setup answers and other stored data you asked us to remove. Your account remains open. A record of this request is retained for compliance purposes." 7. Log audit row (Section 4.1). Not applicable to an account with no billing customer row; see the note at the top of this section. 8. Mark FreeScout ticket resolved.


4. Audit Trail

Every completed deletion / anonymization DSR must generate an audit row in billing_action_log (the Raptor table from backend_v2/alembic/versions/0058_billing_customers_pg.py; the Console's console_billing_action_log is a different table). This is not applicable to an account with no billing customer row (Section 2.3): the record is the dsr_log rows or the console.customer.deleted audit row, the FreeScout ticket and the tracking spreadsheet (Section 4.2).

Access and Correction requests get no billing_action_log row on any account. The table's ck_bal_action CHECK admits only dsr_initiated, dsr_confirmation_sent, dsr_customer_confirmed, dsr_scheduled, dsr_executing, dsr_anonymize_completed, dsr_full_delete_completed, dsr_canceled and retention_anonymize, and none of them describes an Access or a Correction. Do not borrow a deletion action for them; the record is the FreeScout ticket and the tracking spreadsheet (Section 4.2). Adding an action for them is a schema change (extend ck_bal_action and its SQLite twin), not an edit to this SOP.

4.1 billing_action_log entry template

For a deletion / anonymization of an account with a billing customer row. Run it once the anonymization is done and checked: the table is append-only, so a wrong row cannot be edited afterwards. <customer_id> is billing_customers.id. id and recorded_at fill themselves in (recorded_at is an ISO-8601 UTC text timestamp). payload is JSON text and must not hold the email, name or address.

INSERT INTO billing_action_log (
    action,
    actor_id,
    entity_type,
    entity_id,
    payload
)
VALUES (
    'dsr_anonymize_completed',
    '<operator_github_handle>',
    'billing_customer',
    '<customer_id>',
    jsonb_build_object(
        'dsr_type',         'deletion',
        'freescout_ticket', '<ticket_id>',
        'completed_at',     to_char(NOW() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'),
        'notes',            '<brief summary, no personal data>'
    )::text
);

4.2 Manual tracking during the manual era

Until automated tooling ships (#1630), also track each DSR in the DSR tracking spreadsheet:

Column Value
Ticket ID FreeScout ticket number
Request type access / correction / deletion
Received (UTC) Day 0 timestamp
Verified (UTC) Timestamp of identity verification
Completed (UTC) Timestamp of completion
SLA deadline Day 0 + 45 days
Processed by GitHub handle
Notes Any edge cases or escalations

5. 45-Day SLA Monitoring

CPRA/CCPA extension rule (primary): Cal. Civ. Code § 1798.100(b) — the initial response window is 45 days. A single 45-day extension is permitted (total 90 days) if the operator notifies the requester of the extension and the reason before the initial 45-day period expires.

GDPR extension note: GDPR Art. 12(3) sets a 30-day baseline with up to 2-month extension (total 3 months) for complex or numerous requests, with notice within the first month. If EU residents are in scope in a future release, the GDPR window is shorter and is the binding deadline for those requests. For v1 (US-only, California geo-target), CPRA governs.


6. SQL Templates

6.1 Access Export Query

-- DSR Access Export — returns all PII fields for one customer
-- Replace '<customer_id>' with the actual customer UUID or ID
SELECT
    id,
    email,
    name,
    address_line1,
    address_line2,
    address_city,
    address_state,
    address_postal,
    address_country,
    acquisition_source,
    customer_segment,
    late_payment_count,
    chargeback_count,
    failed_charge_count,
    stripe_customer_id,
    created_at,
    updated_at,
    anonymized_at
FROM billing_customer
WHERE id = '<customer_id>';

Export the result as JSON or CSV and include in the access response.

For invoice history, additionally query:

-- Invoice history for DSR access export
SELECT
    id,
    amount,
    currency,
    status,
    created_at,
    period_start,
    period_end
FROM invoices
WHERE customer_id = '<customer_id>'
ORDER BY created_at DESC;

6.2 Deletion / Anonymization SQL

Run both statements in a single transaction. The idempotency guard (AND anonymized_at IS NULL) prevents double-anonymization.

BEGIN;

-- Step 1: Anonymize PII on billing_customer
-- Idempotency guard: only runs if anonymized_at is NULL (not already anonymized)
UPDATE billing_customer
SET
    email          = 'anonymized-' || gen_random_uuid() || '@deleted.local',
    name           = NULL,
    address_line1  = NULL,
    address_line2  = NULL,
    address_city   = NULL,
    address_state  = NULL,
    address_postal = NULL,
    address_country = NULL,
    anonymized_at  = NOW()
WHERE id = '<customer_id>'
  AND anonymized_at IS NULL;

-- Step 2: Verify anonymization completed (row count should be 1)
-- If 0 rows updated, the row was already anonymized — check anonymized_at
SELECT id, email, anonymized_at
FROM billing_customer
WHERE id = '<customer_id>';

-- Step 3: Confirm invoice rows are intact (must NOT be deleted)
SELECT COUNT(*) AS invoice_count
FROM invoices
WHERE customer_id = '<customer_id>';

COMMIT;

After running: Verify anonymized_at is set and the email is in anonymized-<uuid>@deleted.local format. Confirm invoice count is as expected (invoices are never deleted).

Note on address_country: Per docs/ops/policies/data-retention.md, address_country may need to be retained for tax nexus purposes. If the CPA confirms this, replace address_country = NULL above with a comment that this field is retained. Review this with the CPA before first deletion.


6.3 Audit Log Insert (full example)

-- After completing anonymization of an account with a billing customer row, insert the audit row.
-- Not for an account with no billing customer row, nor for Access / Correction (Section 4).
INSERT INTO billing_action_log (
    action,
    actor_id,
    entity_type,
    entity_id,
    payload
)
VALUES (
    'dsr_anonymize_completed',
    'kristerpher',
    'billing_customer',
    '<customer_id>',
    jsonb_build_object(
        'dsr_type',         'deletion',
        'freescout_ticket', 'FS-1234',
        'completed_at',     to_char(NOW() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'),
        'notes',            'Anonymization completed per DSR request received 2026-05-XX UTC'
    )::text
);

7. Decision Tree — Edge Cases

7.1 Customer has an open/active subscription

Question: Can we delete a customer who is still subscribed?

Decision: 1. First, cancel the subscription in Stripe (customer must confirm they want to cancel, or operator cancels per their request). 2. Confirm any pending payments are settled (or note them as outstanding disputes). 3. Then proceed with anonymization per Section 3.3.

Do not: Anonymize a billing_customer row that has an active Stripe subscription without first canceling in Stripe — this will create a broken billing state.


7.2 Customer has pending or disputed payments

Question: Can we anonymize a customer who has an open Stripe dispute or unpaid invoice?

Decision: 1. If a Stripe chargeback dispute is open: defer anonymization until the dispute is resolved. Notify the requester:

"Your deletion request is acknowledged. We are unable to complete anonymization while an open billing dispute is pending. We will process your request within [X] days of dispute resolution. This is permitted under GDPR Art. 17(3)(e) (legal claims)." 2. If an invoice is unpaid: confirm with the operator before proceeding. The outstanding amount may represent a legal claim that justifies retention. 3. Document the reason for deferral in the FreeScout ticket. billing_action_log has no action for a deferral (none of the actions listed in Section 4 fits), so do not write a row there.


7.3 GDPR Article 17(3) Exemptions — When to refuse erasure with explanation

GDPR Art. 17(3) permits refusing erasure where retention is necessary for:

Exemption Art. 17(3) basis Example for Raxx
Compliance with a legal obligation Art. 17(3)(b) 7-year tax retention — invoice records must be retained
Establishment, exercise, or defense of legal claims Art. 17(3)(e) Open Stripe chargeback dispute; unpaid invoice; pending litigation
Public interest (archiving, research, statistics) Art. 17(3)(d) Not applicable for Raxx at v1
Freedom of expression Art. 17(3)(a) Not applicable

When to use Art. 17(3): 1. Always use it for invoice records — explain that invoices must be retained for 7 years per tax law. 2. Use it for open disputes — explain that records are retained to exercise/defend a legal claim. 3. Do NOT use it to refuse PII anonymization (name, email, address) — these can and should be anonymized even when invoice records are retained.

Response template for partial refusal (tax retention):

"We have anonymized your personal information (name, email address, and billing address) in our systems. We are required by law to retain invoice and transaction records for 7 years for tax and accounting compliance. These retained records are no longer linked to your name or contact details."


7.4 Requester is not the account holder (proxy request)

If a person claims to act on behalf of an account holder (e.g., a lawyer, family member):

  1. Require written authorization from the account holder.
  2. Verify the authorization before proceeding.
  3. Document the authorization in the FreeScout ticket.

8. Checklist — Per-DSR Completion


This SOP must be reviewed annually. Next review: 2027-05-01 UTC. Automated DSR tooling target: 2026-Q3 (#1630) — update this SOP when that ships.