Skip to content
This repository was archived by the owner on Aug 12, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
54d0b92
docs: add delete-my-account plan
lourou Jul 10, 2026
4020335
docs(delete-account): link tombstone gate to subscription ownership r…
lourou Jul 13, 2026
1971741
docs(delete-account): address review findings
lourou Jul 13, 2026
aeeaea1
docs(delete-account): serialize account-linked writers via parent-row…
lourou Jul 13, 2026
ac86f51
feat(deletion): add barrier, deletion-record, outbox, and tombstone m…
lourou Jul 15, 2026
4f0fffb
feat(deletion): barrier at token mint, fail-closed requireAccount, li…
lourou Jul 15, 2026
f791707
feat(deletion): honor subscription tombstones in verify and store web…
lourou Jul 15, 2026
e9f01f4
feat(deletion): DELETE /v2/accounts/me with single-transaction teardown
lourou Jul 15, 2026
9e505d1
feat(deletion): outbox drain worker and external purge executors
lourou Jul 15, 2026
ff1c55f
feat(claim): subscription lineage, custody escrow, and the claim endp…
lourou Jul 15, 2026
e56591a
test(deletion): adversarial invariants, schema guards, and spec amend…
lourou Jul 15, 2026
ce9179b
fix(db): make reclaim migrations additive and database-enforce money …
lourou Jul 15, 2026
c2e8cec
fix(subscriptions): order-identity Google accounting, atomic lineage …
lourou Jul 15, 2026
d6db53a
fix(deletion): co-serialize barrier with mint, fence every account ro…
lourou Jul 15, 2026
9a364bc
fix(claim): fail-closed attestation flag, real pending-transfer push,…
lourou Jul 15, 2026
f725250
test(deletion): round-3 adversarial invariants, router fencing audit,…
lourou Jul 15, 2026
3727d99
fix(claim): exact funding-event restoration, keyless claim fail-close…
lourou Jul 15, 2026
2bc0935
fix(subscriptions): atomic tombstone rotation absorption, fail-closed…
lourou Jul 15, 2026
2ce3974
fix(auth): lossless activity veto, exact delete-replay carve-out, fai…
lourou Jul 15, 2026
5ee7214
feat(subscriptions): reclaim reconciliation sweep (quarantine drain +…
lourou Jul 15, 2026
2e028a3
test(deletion): round-4 invariants — exact-key restoration, veto race…
lourou Jul 15, 2026
3464e24
fix(auth): fail closed when the activity stamp cannot be written
lourou Jul 15, 2026
c673617
feat(subscriptions): durable reconciliation progress state
lourou Jul 15, 2026
296142e
fix(claim): fail closed when restoration finds no funding-event custody
lourou Jul 15, 2026
90a316c
feat(subscriptions): rework reconciliation sweep - retry state, drift…
lourou Jul 15, 2026
98f4de5
test(deletion): round-5 invariants - veto fail-close, drift cursor, f…
lourou Jul 15, 2026
e415a82
fix(reconciliation): composite drift cursor, DB-owned commit time, ro…
lourou Jul 15, 2026
53e389e
test(deletion): cover drift cursor boundaries and mint-stamp failure
lourou Jul 15, 2026
a302528
fix(reconciliation): durable per-lineage drift scheduling, rolling-sa…
lourou Jul 15, 2026
6f3b657
test(deletion): cover drift scheduling fairness, escalation, and back…
lourou Jul 15, 2026
4c4ab71
fix(reconciliation): final provider check at drift deadline and bound…
lourou Jul 15, 2026
4c343ab
fix(migrations): normalize all committed transfer timestamps and free…
lourou Jul 15, 2026
6226698
refactor(subscriptions): defer live transfer, Google claim proof, and…
lourou Jul 16, 2026
6524bb6
test(deletion): retarget the suite at the launch scope and consolidat…
lourou Jul 16, 2026
827f717
fix(deletion): harden purge executors, claim contract, and outbox dra…
lourou Jul 16, 2026
f4788ba
fix(deletion): close purge and fencing gaps from adversarial review
lourou Jul 16, 2026
1cf9743
fix(deletion): fence outbox claim generations and multi-owner notific…
lourou Jul 16, 2026
4678ba1
test(deletion): describe the replay diagnostics in current-behavior t…
lourou Jul 16, 2026
90b578d
fix(deletion): bound and fence remote notification mutations across t…
lourou Jul 16, 2026
d0f3d85
fix(migration): canonicalize Google lineage backfill to chain roots a…
lourou Jul 16, 2026
944cb5e
fix(subscriptions): keep keyless-void quarantine rows operator-owned
lourou Jul 16, 2026
ee946f6
fix(subscriptions): seed billing-grace claims from the status item's …
lourou Jul 16, 2026
d6918d4
feat(deletion): gate the endpoint by ACCOUNT_DELETION_ENABLED env var
lourou Jul 23, 2026
1a9f19d
Merge remote-tracking branch 'origin/otr-dev' into feature/delete-acc…
lourou Jul 24, 2026
37540e6
fix(deletion): report drain counts when only the advisory-lease close…
lourou Jul 24, 2026
c255695
chore(env): order deletion-section keys per dotenv-linter
lourou Jul 24, 2026
45cd889
fix(deletion): silence false-positive narrowing lint on the lease-clo…
lourou Jul 24, 2026
1a02af5
Merge remote-tracking branch 'origin/otr-dev' into feature/delete-acc…
lourou Aug 4, 2026
b735eb2
Merge remote-tracking branch 'origin/otr-dev' into feature/delete-acc…
lourou Aug 4, 2026
24e65af
fix(deletion): complete posthog_person purge as an explicit skip when…
lourou Aug 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,29 @@ SIWE_ALLOWED_CHAIN_IDS=1
# Generate with: openssl rand -hex 32
# Treat as a secret; rotate via deploy if compromised (invalidates in-flight nonces, 5-min TTL absorbs).
NONCE_HMAC_SECRET=
# Rollout gate for DELETE /v2/accounts/me — fail-closed, only the exact
# string "true" enables it (flip = infra PR + task-definition roll).
ACCOUNT_DELETION_ENABLED=false
# REQUIRED — HMAC secret keying account-deletion barrier hashes and pseudonymous
# deletion-record refs. Must be >= 64 hex chars (32 bytes).
# Generate with: openssl rand -hex 32
# PERMANENT: never rotate — rotation orphans every DeletedIdentity barrier row
# (silently lifting the deletion bar) and breaks deletion-record lookups.
DELETION_HASH_SECRET=
# OPTIONAL — PostHog person deletion for account-deletion purges. The
# ingestion token cannot delete persons; these enable the private-API call.
# When unset, posthog purge tasks complete as "skipped: not configured"
# (logged as deletion.purge.posthog_person_skipped, never silent): persons
# hold only pseudonymous ids + behavioral counters, and the accountId
# mapping dies with the Account row. Set both to resume hard purging.
POSTHOG_API_HOST=
POSTHOG_PERSONAL_API_KEY=
POSTHOG_PROJECT_ID=

# --- Subscription restoration ---
# Claims of deleted accounts' Apple subscriptions.
SUBSCRIPTION_CLAIM_TOMBSTONE_ENABLED=true
# Live ownership transfers and Google claim proof are deferred to a follow-up.

# --- Payments / Credits ---
# REQUIRED — All five PAYMENTS_* knobs below are hard-required. Backend
Expand Down
138 changes: 105 additions & 33 deletions docs/plans/delete-my-account.md
Original file line number Diff line number Diff line change
Expand Up @@ -465,8 +465,11 @@ cannot mint tokens".
### Phase 2: billing tombstones

- [ ] Provider-key tombstone model; no-op handling in Apple and Google
webhook processing and in subscription verification; token-rotation
absorption; deletion-vs-webhook concurrency semantics.
webhook processing and in subscription verification; recursive alias
resolution for live ingest; deletion-vs-webhook concurrency semantics.
- [ ] Tombstoned-lineage token-rotation absorption bookkeeping is deferred
with Google claim restoration. Rotation events on tombstoned lineages
remain counted no-ops until that follow-up ships.

### Phase 3: external purges and retention enforcement

Expand Down Expand Up @@ -544,49 +547,118 @@ cannot mint tokens".
credits (a ledger `forfeitSubscriptionPeriod` before the wallet goes),
or is deleting the wallet itself sufficient erasure?

## Decided contract and defaults (as built)

The cross-repo wire contract (agreed with the companion iOS plan) and the
open-question resolutions this implementation shipped with:

- **Route/body**: `DELETE /v2/accounts/me`, JSON body `{ "operationId":
"<uuid v4, client-generated, persisted before first send>" }`; 200
`{ "status": "deleted", "operationId", "deletedAt", "purgeWindowHours": 24 }`.
Replays - same or different operationId, via the endpoint-specific
carve-out - return the stored record, echoing the stored operationId.
- **Terminal identity-deleted**: 410 `{ "error", "code": "identity_deleted" }`
at `POST /v2/auth/token`, only after full SIWE validation (no
unauthenticated deletion oracle). The delete-200 and this 410 are the only
confirmation channels.
- **Fail-closed requireAccount**: deleted account with an unexpired token
gets a generic 401 on every other route; no positive existence caching
anywhere on this boundary - every check hits the database.
- **Verify claimable signal**: ownership-mismatch/tombstone 409s keep code
`subscription_account_mismatch` (append-only law) and gain the additive
`claimable` boolean.
- **Barrier**: permanent, keyed hash (HMAC keyed by the dedicated
`DELETION_HASH_SECRET`, which must never rotate).
- **Fresh-token requirement**: not in v1 (rate limits + audit instead).
- **Forfeit-before-wallet-delete**: superseded by custody escrow - the
deletion transaction escrows the conservative period remainder for a
future claim instead of just forfeiting it.
- **AdminAudit**: pre-existing entries retained as-is (ops carve-out); the
deletion entry uses a sentinel account id with the keyed accountRef in
`reason`.
- **Retention defaults**: BillingReceipt and CreditLedger rows are deleted
outright (swappable single point: `deleteWalletForAccountWithTx` in the
ledger module); the tombstoned lineage plus custody/registry rows are the
pseudonymized retained billing trace; the DeletionRecord and outbox rows
expire 30 days after the drain completes.
- **Untracked S3 attachments**: retain-and-disclose (immutable message
content); bucket lifecycle policy is an ops follow-up.
- **Legacy avatar objects**: unscoped `a/<uuid>` keys have no provable owner
and are not purged by account teardown. New uploads use
`a/<accountId>/<uuid>`; bucket lifecycle policy remains the ops follow-up
for legacy unscoped objects.
- **PostHog**: person deletion via the private API (new optional
`POSTHOG_PERSONAL_API_KEY` / `POSTHOG_PROJECT_ID`); when analytics is on
and the credentials are missing, purge tasks retry and page ops.
- **Purge SLA**: 24 hours, returned as `purgeWindowHours` and alerted on
breach (`deletion.purge.sla_breach`).
- **Ops kill switch**: env var `ACCOUNT_DELETION_ENABLED` (fail-closed,
default off) gates the endpoint; flips ship as an infra PR + task-definition
roll (dev true, prod false until launch). Ops enables it only after the
full rollout; the same switch remains the emergency kill switch.

## References

- Companion client plan: convos-ios repo, `docs/plans/delete-my-account.md`.
- Apple App Store Review Guideline 5.1.1(v) (account deletion requirement).
- Apple developer guidance: "Provide options to delete your app's account".

## Relationship to subscription ownership reconciliation

The provider-key subscription tombstone proposed here supplies the safety gate
that Option A in `docs/plans/subscription-ownership-reconciliation.md` lacks.
The two plans should compose in this order:

1. Ship account deletion as detach+tombstone: detach the `Subscription` from
the deleted `Account`, retain only the minimal provider-key tombstone, and
make webhooks and verify fail closed. This delivers deletion compliant with
Apple App Store Guideline 5.1.1(v) before introducing ownership transfer.
2. Ship Option B's mismatch detection and telemetry immediately, including
`subscription.verify.account_mismatch` visibility and alerts, while
cross-account verification continues to return 409.
3. Add Option A only when a fresh provider-verified transaction targets a
provider key whose prior owner is represented by a committed deletion
tombstone, and the new account makes an explicit, one-time ownership claim
(a deliberate restore/claim act, not a background verify). The tombstone
proves the old owner is dead; it does not by itself prove the caller owns
the entitlement, so possession of a provider key or a replayable signed
payload alone must never transfer. Absent a valid claim, verify keeps
failing cross-account and manual, support-mediated transfer remains the
fallback. Under those two gates, transfer heals a paying user's
entitlement without turning an ordinary ownership mismatch into a
subscription hijack vector.

The July 12-13 incident demonstrates the need: account recreation orphaned
## Relationship to subscription ownership reconciliation (as built)

This section originally proposed tombstone-gated transfer only. The
implementation supersedes it with the subscription-lineage claim design
(adversarially reviewed; see the claim section below). The July 12-13
incident remains the motivating case: account recreation orphaned
subscriptions, leaving the new account with a verify 409 while renewals kept
enriching the ghost account's wallet.

## Subscription claim (as built)

One `SubscriptionLineage` row per purchase line (Apple originalTransactionId;
Google linkedPurchaseToken chain resolved to its root, rotated tokens kept as
aliases) is the canonical first lock for verify, webhooks, claims, and the
deletion teardown, the cooldown anchor, and the tombstone carrier: deletion
flips the lineage to `tombstoned` instead of writing a separate tombstone
table. `LineagePeriodGrant` makes period funding global-once (keyed by the
provider funding event: Apple transactionId / Google latestOrderId), and
`LineagePeriodCustody` tracks each funded period's remaining value; every
move debits `D = min(lockedBalance, max(0, cap - consumesSince))` and sets
`cap := D`, so no sequence of delete/claim/undo/refund events can move more
than one period allotment and commingled promo/admin/signup credits never
transfer.

`POST /v2/accounts/me/subscription/claim` is the
explicit one-time claim act:

- Proof requirements are authoritative: verified artifact, provider-confirmed
entitled-now, and latest-transaction match (no signedDate freshness window
- it is not a challenge). Firebase App Check attestation with a
limited-use, consumed token is mandatory and fails closed; there is no
`app_attest_enabled` bypass on this route.
- Tombstone restoration (deleted owner): the deletion transaction escrowed
the conservative remainder into custody; the claim releases the escrow to
the claimant (never a second grant) and flips the lineage back to live.
Enabled at launch (`SUBSCRIPTION_CLAIM_TOMBSTONE_ENABLED`).
- Live bearer-transfer (owner still exists): behind
`SUBSCRIPTION_CLAIM_LIVE_TRANSFER_ENABLED` (off until security sign-off),
with a 72-hour contest window by default (202 pending; the old account's
devices are push-notified; any authenticated act by the old account before
settlement vetoes), a 30-day per-lineage cooldown, and a one-shot CAS undo
for the immediately previous owner - cooldown-exempt, executes
immediately, and freezes further automated transfers on the lineage
(operator re-home only). Recovery language is honest: the previous owner
can recover once, within 30 days; after the undo is spent, the deadline
passes, or the lineage moves on again, recovery is support-mediated.
- Deviation from the original section: claims work without a deletion
tombstone (bounded bearer-transfer semantics), because the primary heal
class - ghost accounts whose keys are gone - can never produce an
old-owner approval, and the consequences are bounded by conservation,
attestation, cooldown, contest window, undo, journaling, and alerting.

## Design decisions (agreed 2026-07-22)

Agreed between Louis and Borja after the ledger retention review and the
first end-to-end device deletions. These decisions resolve the retention,
forfeit, and transfer-policy open questions above; the mechanisms they name
(SubscriptionLineage, period custody/escrow, LineagePeriodGrant,
DeletionRecord, DeletedIdentity) are built on the implementation branch
(`feature/delete-account-live-transfer`).
first end-to-end device deletions.

- **Immediate deletion on request.** No defer-to-subscription-expiry
variant: Apple 5.1.1(v) and GDPR erasure timing both point at deleting
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
-- Account deletion: barrier, deletion record, purge outbox, billing
-- tombstones, and the lastAuthAt activity stamp. Purely additive.

-- AlterTable
ALTER TABLE "Account" ADD COLUMN "lastAuthAt" TIMESTAMP(3);

-- CreateTable
CREATE TABLE "DeletedIdentity" (
"identityHash" TEXT NOT NULL,
"deletedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "DeletedIdentity_pkey" PRIMARY KEY ("identityHash")
);

-- CreateTable
CREATE TABLE "DeletionRecord" (
"operationId" UUID NOT NULL,
"accountRef" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'purging',
"requestedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"completedAt" TIMESTAMP(3),
"expiresAt" TIMESTAMP(3),
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "DeletionRecord_pkey" PRIMARY KEY ("operationId")
);

-- CreateTable
CREATE TABLE "DeletionTask" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"operationId" UUID NOT NULL,
"kind" TEXT NOT NULL,
"payload" JSONB NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"attempts" INTEGER NOT NULL DEFAULT 0,
"nextAttemptAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastError" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"completedAt" TIMESTAMP(3),

CONSTRAINT "DeletionTask_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "SubscriptionTombstone" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"provider" "BillingProvider" NOT NULL,
"providerKey" TEXT NOT NULL,
"accountRef" TEXT NOT NULL,
"deletedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "SubscriptionTombstone_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE INDEX "DeletionRecord_accountRef_idx" ON "DeletionRecord"("accountRef");

-- CreateIndex
CREATE INDEX "DeletionRecord_status_requestedAt_idx" ON "DeletionRecord"("status", "requestedAt");

-- CreateIndex
CREATE INDEX "DeletionRecord_expiresAt_idx" ON "DeletionRecord"("expiresAt");

-- CreateIndex
CREATE INDEX "DeletionTask_status_nextAttemptAt_idx" ON "DeletionTask"("status", "nextAttemptAt");

-- CreateIndex
CREATE INDEX "DeletionTask_operationId_idx" ON "DeletionTask"("operationId");

-- CreateIndex
CREATE INDEX "SubscriptionTombstone_accountRef_idx" ON "SubscriptionTombstone"("accountRef");

-- CreateIndex
CREATE UNIQUE INDEX "SubscriptionTombstone_provider_providerKey_key" ON "SubscriptionTombstone"("provider", "providerKey");

-- Backfill: existing accounts start their activity clock at migration time.
-- A null lastAuthAt must never read as "inactive/no veto"; after this
-- backfill, null only ever means a brand-new account that has not minted yet.
UPDATE "Account" SET "lastAuthAt" = CURRENT_TIMESTAMP WHERE "lastAuthAt" IS NULL;
Loading
Loading