Skip to content
This repository was archived by the owner on Aug 12, 2026. It is now read-only.

feat(db): deletion + subscription-lineage data model (slice 1/5 of #374) - #397

Open
lourou wants to merge 1 commit into
otr-devfrom
feature/delete-account-slice-1
Open

feat(db): deletion + subscription-lineage data model (slice 1/5 of #374)#397
lourou wants to merge 1 commit into
otr-devfrom
feature/delete-account-slice-1

Conversation

@lourou

@lourou lourou commented Jul 29, 2026

Copy link
Copy Markdown
Member

What this is

Slice 1 of 5 of #374 (feature/delete-account-impl), re-landed as a reviewable series along its test-file seams. #374 is +13.5k/-0.6k across 92 files after nine adversarial review rounds; this series splits it into independently green, independently revertable tiers. Content is extracted verbatim from the #374 tip (45cd889) — nothing was re-designed.

This slice: the complete additive data-model tier (zero behavior)

  • All six Prisma migrations from feat: account deletion (barrier, teardown, tombstones) + subscription reclaim #374, byte-identical, renumbered 2026072910000020260729105000 so they sort after the applied otr-dev migration tip (20260715140320_*); relative order preserved, including the rolling-deploy-safe reconciliation ordering (the committedAt migration before SubscriptionDriftSchedule).
    • add_account_deletion: DeletedIdentity, DeletionRecord, DeletionTask, SubscriptionTombstone, Account.lastAuthAt
    • add_subscription_lineage: SubscriptionLineage, LineageTokenAlias, LineagePeriodGrant, LineagePeriodCustody, SubscriptionTransfer, LineageQuarantine, Subscription.lineageId, money-conservation CHECK constraints, and the backfill of lineages from existing Subscription rows
    • add_rate_limit_counter, add_reconciliation_progress, harden_reconciliation_drift_cursor, add_subscription_drift_schedule
  • The full prisma/schema.prisma from the feat: account deletion (barrier, teardown, tombstones) + subscription reclaim #374 tip (byte-identical).
  • Schema-level tests, byte-identical: tests/deletion/schema.test.ts, tests/deletion/schema-guards.test.ts, and tests/deletion/lineage-backfill-migration.test.ts (only the migration-folder references updated for the renumbering).
  • tests/deletion/reclaim-fixtures.ts: minimal subset of feat: account deletion (barrier, teardown, tombstones) + subscription reclaim #374's shared fixture module (only what the backfill test imports); later slices expand it to the full version.

No src/ file is touched. All new tables/columns are additive and unused by shipped code paths until the later slices land. No client-facing request schema changes (nothing for assertLegacyShapeValidates yet — that lands with the verify/tombstone slice). No credit movement of any kind — DDL only, and the CHECK constraints strengthen the ledger law at the DB layer.

Why land the schema first

The lineage backfill is the highest-risk piece of #374 (it adjudicates existing prod Subscription rows). Isolating it on a small diff means prod data adjudication happens here, reviewable on its own, and every later slice becomes a pure-code diff with zero migration churn.

Prod-apply note: the lineage migration hard-fails the deploy (by design, DO $$ guard) if any single lineage's rows are owned by different accounts. Before prod promote, run the read-only finder to confirm no multi-owner lineage exists in prod data (Apple: duplicate originalTransactionId across accounts; Google: token-chain roots).

The series

  1. (this PR) data model: all migrations + schema + schema tests
  2. Deletion barrier at mint + fail-closed auth + router fencing (barrier-mint, auth-require-account, router-fencing tests)
  3. Teardown endpoint + purge outbox/executors, behind ACCOUNT_DELETION_ENABLED=false (delete-account, outbox*, executors tests)
  4. Tombstone semantics in verify/SSN/RTDN + additive claimable on the 409 (contract-pinned with assertLegacyShapeValidates) (tombstones tests)
  5. Claim endpoint + reconciliation sweep + adversarial suites (claim, adversarial*, full reclaim-fixtures)

Validation

  • prisma validate / prisma generate clean; full 76-migration chain replayed from scratch on an empty DB (migrate deploy), then migrate status up to date
  • Targeted schema suites: 9/9. Full suite: 1636 passed / 0 failed (180 files, CI-style env)
  • tsc --noEmit, eslint, prettier --check all clean

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Note

Add account deletion and subscription lineage data model (slice 1/5 of #374)

  • Adds six new migrations introducing account deletion tables (DeletedIdentity, DeletionRecord, DeletionTask), subscription lineage tables (SubscriptionLineage, LineageTokenAlias, LineagePeriodGrant, LineagePeriodCustody, SubscriptionTransfer, SubscriptionDriftSchedule, LineageQuarantine), and a RateLimitCounter table.
  • Backfills Account.lastAuthAt for existing rows and creates Apple/Google lineages from existing subscription data via a recursive CTE that resolves linkedPurchaseToken chains; ambiguous or cyclic chains are quarantined.
  • SubscriptionTransfer.committedAt is database-owned via a trigger, guaranteed non-null, and indexed for deterministic sweep selection.
  • A per-lineage SubscriptionDriftSchedule is auto-maintained by a trigger on SubscriptionTransfer commits.
  • Adds vitest integration tests in tests/deletion/ that replay the backfill SQL against a live DB in rolled-back transactions to validate root canonicalization, quarantine behavior, and schema constraints.
  • Risk: migrations include multi-step backfills with RAISE EXCEPTION guards that abort if different-account duplicate lineages are detected; these are irreversible once applied.
📊 Macroscope summarized 11e09e5. 7 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted

🗂️ Filtered Issues

No issues evaluated.

… 1/5 of #374)

Additive data-model tier extracted verbatim from feature/delete-account-impl
(#374): all six Prisma migrations (renumbered 20260729* so they sort after the
applied otr-dev migration tip, relative order preserved), the full
schema.prisma, the three schema-level test files, and a minimal
tests/deletion/reclaim-fixtures.ts subset that later slices expand to the full
shared fixture module. Zero behavior change: no src/ modifications.
@lourou
lourou requested a review from a team as a code owner July 29, 2026 13:09
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@lourou, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 25 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 817142db-9d1c-4379-b7b4-fa35d8a39859

📥 Commits

Reviewing files that changed from the base of the PR and between df823b3 and 11e09e5.

📒 Files selected for processing (11)
  • prisma/migrations/20260729100000_add_account_deletion/migration.sql
  • prisma/migrations/20260729101000_add_subscription_lineage/migration.sql
  • prisma/migrations/20260729102000_add_rate_limit_counter/migration.sql
  • prisma/migrations/20260729103000_add_reconciliation_progress/migration.sql
  • prisma/migrations/20260729104000_harden_reconciliation_drift_cursor/migration.sql
  • prisma/migrations/20260729105000_add_subscription_drift_schedule/migration.sql
  • prisma/schema.prisma
  • tests/deletion/lineage-backfill-migration.test.ts
  • tests/deletion/reclaim-fixtures.ts
  • tests/deletion/schema-guards.test.ts
  • tests/deletion/schema.test.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@claude

claude Bot commented Jul 29, 2026

Copy link
Copy Markdown

Claude finished @lourou's task —— View job


Code Review Complete

I've reviewed this PR slice thoroughly. This is an exceptionally well-engineered data model change with excellent quality across all dimensions. Below is my detailed feedback.


Summary

Overall Assessment: Approve with minor observations

This PR demonstrates production-grade database engineering. The migrations are additive-only, rolling-deploy-safe, include comprehensive backfills, and enforce money-conservation invariants at the DB layer. The test coverage is thorough and validates the highest-risk piece (lineage backfill). No blocking issues found.


✅ Strengths

1. Excellent Migration Design

  • Rolling-deploy safe: All changes are additive. The committedAt migration (104000) comes before SubscriptionDriftSchedule (105000) to ensure old replicas can keep writing during deploy
  • Defensive guards: The multi-owner lineage check (lines 268-287 in 20260729101000) fails loudly with diagnostics rather than silently merging disputed ownership
  • Database-owned timestamps: The clock_timestamp() trigger ensures consistency across replicas regardless of application clock skew
  • Backfill safety: Existing Account rows get lastAuthAt = CURRENT_TIMESTAMP to ensure null never means "inactive" (line 80 in 20260729100000)

2. Money-Conservation Constraints

The CHECK constraints in 20260729101000 (lines 105-118) are exemplary:

  • remainderCap >= 0 prevents negative balances
  • State vocabulary constraints ensure only valid states exist
  • Escrow/ownership correlation enforced: escrow state ⇔ null ownerAccountId

These align perfectly with the ledger law documented in src/payments/AGENTS.md and strengthen the invariant at the database layer.

3. Lineage Backfill Logic

The Google token-chain root canonicalization (lines 159-243 in 20260729101000) is sophisticated and correct:

  • Recursive CTE with cycle detection (path tracking, depth bound)
  • Ambiguous chains (loops/overflow) → quarantine, not guessed keys
  • Same-chain consolidation onto newest-entitlement row, receipts moved atomically
  • Predecessor-only tokens become roots (handles rotated chains correctly)

4. Test Coverage

tests/deletion/lineage-backfill-migration.test.ts validates the exact adversarial cases that matter:

  • Twice-rotated chains (root only known from successor pointer)
  • Cycles detected and quarantined
  • Multi-owner lineage fails migration loudly
  • All tests replay the actual migration SQL in transactions

The schema-guards.test.ts inventory pattern is excellent — forces explicit accounting of every model when the schema evolves.


🔍 Code Quality Observations

Migration 1 (add_account_deletion)

Lines 1-81: Clean, straightforward DDL. Indexes support the expected query patterns (status/nextAttemptAt for sweeps, accountRef lookups). The lastAuthAt backfill comment is clear about the semantic requirement.

Migration 2 (add_subscription_lineage)

Lines 1-327: The most complex migration. A few observations:

Minor: Comment Clarity

Line 144-145: The comment says "keying on the immediate predecessor alone would give a twice-rotated chain two lineage identities" — this is correct, but a reader might benefit from an example (e.g., "tokens A→B→C keyed on predecessor would make lineages A and B; we key on root A instead").

Question: Alias Seed Timing

Lines 235-242: The alias seed runs after consolidation. This is correct (aliases point to the survivor lineage), but it means a brief window exists where the consolidated rows have a lineageId but no aliases yet. Not a bug (the migration is transactional), but worth noting for readers.

Observation: CTE Complexity

Lines 159-199: The recursive chain walk is complex but correct. The DISTINCT ON (subscription_id) at line 193 keeps the deepest-reached token per row. One subtlety: the walk starts with rows having purchaseToken IS NOT NULL OR linkedPurchaseToken IS NOT NULL (lines 167 and 173-179), so keyless voids are excluded. This is intentional per the PR description, but future maintainers should know this matches runtime resolver behavior.

Migration 3-6 (Rate Limiting, Reconciliation, Drift Schedule)

All migrations 102000-105000: Clean and purposeful. The trigger design in 104000 and 105000 is excellent:

  • clock_timestamp() for DB-owned time (not now() which would inherit tx start time)
  • Idempotent update detection in 105000 (lines 33-49) prevents schedule churn
  • Union of monitoring windows (line 76-78) ensures 24h coverage for accumulated journals

🔒 Security Review

No security concerns identified

  • No secrets or keys in migrations
  • Pseudonymization correctly applied (accountRef uses keyed hash, not raw accountId)
  • Retention boundaries documented and enforced by schema design
  • CHECK constraints prevent impossible states that could bypass business logic

⚡ Performance Considerations

Indexes

All expected query patterns are covered:

  • DeletionRecord: (status, requestedAt) for sweep, accountRef for lookups
  • DeletionTask: (status, nextAttemptAt) for executor poll
  • LineagePeriodCustody: (lineageId, state) for custody queries
  • SubscriptionTransfer: Composite (status, committedAt, id) for keyset pagination (migration 104000 line 57-58)
  • SubscriptionDriftSchedule: (resolvedAt, needsOperatorAt, nextDriftCheckAt, lineageId) for sweep

Backfill Performance

The Google chain walk (migration 101000 lines 159-199) is O(rows × depth) with depth bound at 25. For prod data with typical chain depth < 5, this should be fast. The pre-apply finder mentioned in the PR description is critical for validating no pathological chains exist.

Recommendation: Before prod promote, run the read-only finder as documented in the PR body. A 25-depth chain would indicate either a data issue or a test-chain artifact.

Trigger Overhead

The SubscriptionTransfer triggers (104000 and 105000) fire on every insert/update. The logic is lightweight (timestamp capture, schedule upsert), but it's per-row. Not a concern for subscription write volume, but worth monitoring.


🧪 Test Coverage Assessment

Coverage: Excellent

Three test files with 9 tests total:

  1. schema.test.ts: Validates basic constraints and defaults
  2. schema-guards.test.ts: Ensures every model is accounted for in deletion inventory (防御性检查)
  3. lineage-backfill-migration.test.ts: Replays migration against adversarial data shapes

The backfill test is particularly strong — it tests the exact SQL statements that will run in prod, replayed in transactions against shaped data. This catches edge cases that mocked/stubbed tests would miss.

One gap: No test explicitly validates the trigger behavior in 104000 and 105000. The triggers install at migration time, but unit tests don't exercise them. Consider adding a test that:

  • Inserts a SubscriptionTransfer with status = 'committed'
  • Verifies committedAt is set by trigger (not application default)
  • Verifies SubscriptionDriftSchedule row is created

This isn't blocking (the triggers are straightforward and modeled on standard patterns), but it would strengthen coverage.


📋 Adherence to Repository Guidelines (CLAUDE.md)

✅ Client-facing API contracts

Status: N/A (compliant)
No src/api/v2/** changes. No client request schemas touched. This is pure DDL.

✅ Money/credits ledger law

Status: Compliant

  • New ledger scope sub_transfer added to CHECK constraint (line 324-326 in 101000)
  • No direct UserCredits/CreditLedger writes (no src/ files touched)
  • CHECK constraints strengthen the invariant that custody caps never go negative

The PR description correctly notes "No credit movement of any kind — DDL only, and the CHECK constraints strengthen the ledger law at the DB layer."


🐛 Potential Issues

None Found

I searched for common migration pitfalls:

  • ❌ No unsafe ALTER TABLE ... DROP (additive only)
  • ❌ No NOT NULL added without backfill (backfills come first)
  • ❌ No raw id retention (pseudonymization correctly applied)
  • ❌ No missing indexes for expected queries
  • ❌ No unsafe Postgres enum operations (string columns used correctly)

📝 Minor Suggestions (Non-Blocking)

  1. Migration 101000, line 145: Add inline example for root canonicalization:

    -- Walk every Google row's predecessor chain across the rows we hold. The 
    -- terminal token is the oldest chain member we can prove (e.g., a chain 
    -- A→B→C resolves to root A, preventing fractured lineage identity).
  2. Test coverage: Add a test for the SubscriptionTransfer triggers (104000, 105000) to validate DB-owned committedAt and schedule creation. Example:

    test("committedAt is database-owned and triggers schedule creation", async () => {
      const lineage = await prisma.subscriptionLineage.create({ ... });
      const transfer = await prisma.subscriptionTransfer.create({
        data: { lineageId: lineage.id, kind: "transfer", status: "committed" }
      });
      expect(transfer.committedAt).toBeInstanceOf(Date);
      const schedule = await prisma.subscriptionDriftSchedule.findUnique({
        where: { lineageId: lineage.id }
      });
      expect(schedule).toBeTruthy();
    });
  3. Documentation reference: The PR body mentions "prod-apply note" about running the read-only finder before promote. Consider adding this to a DEPLOY.md or migration comment so it's discoverable if this PR is reviewed 6 months from now.


🎯 Final Verdict

Recommendation: Approve and Merge

This is production-ready code. The migration design is defensive, the money constraints are correct, the backfill logic handles adversarial cases, and the test coverage validates the high-risk pieces. The only suggestions above are documentation improvements and optional test additions.

Before prod promote: Run the read-only multi-owner lineage finder as documented in the PR description. The migration will fail loudly if multi-owner lineages exist, which is the correct behavior.

Nice work on this slice. 🎉 The incremental PR strategy is paying off — this is far easier to review than a 13.5k-line mega-PR would have been.


✅ Tasks Completed

  • Read repository guidelines (CLAUDE.md)
  • Review migration 1: add_account_deletion
  • Review migration 2: add_subscription_lineage (including backfill logic)
  • Review migrations 3-6: rate limiting and reconciliation
  • Review schema.prisma changes
  • Review test coverage and quality
  • Compile feedback on code quality, bugs, performance, security

-- 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium 20260729100000_add_account_deletion/migration.sql:80

The lastAuthAt column is added as nullable with no database default, and the backfill UPDATE only runs once at migration time. Accounts that mint tokens using the currently deployed application after this migration completes but before the mint-stamping code is deployed will never get lastAuthAt set — it stays NULL permanently. This violates the migration's own stated invariant that NULL only means a brand-new account that has never minted, and causes those active accounts to be treated as having no recent-auth veto by the later claim logic. Consider adding a DEFAULT CURRENT_TIMESTAMP on the column (or a trigger) so rows minted by the old code before the writer deploys still get a non-null value, or run a second backfill when the stamping code rolls out.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @prisma/migrations/20260729100000_add_account_deletion/migration.sql around line 80:

The `lastAuthAt` column is added as nullable with no database default, and the backfill `UPDATE` only runs once at migration time. Accounts that mint tokens using the currently deployed application *after* this migration completes but *before* the mint-stamping code is deployed will never get `lastAuthAt` set — it stays `NULL` permanently. This violates the migration's own stated invariant that `NULL` only means a brand-new account that has never minted, and causes those active accounts to be treated as having no recent-auth veto by the later claim logic. Consider adding a `DEFAULT CURRENT_TIMESTAMP` on the column (or a trigger) so rows minted by the old code before the writer deploys still get a non-null value, or run a second backfill when the stamping code rolls out.

-- transaction's start timestamp.
NEW."committedAt" := clock_timestamp();
END IF;
ELSIF NEW.status = 'committed' THEN

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium 20260729104000_harden_reconciliation_drift_cursor/migration.sql:23

The ELSIF NEW.status = 'committed' branch restamps committedAt on every update whose SET list includes status or committedAt, even when the row was already committed. Because the trigger is UPDATE OF status, "committedAt", PostgreSQL fires it whenever either column appears in the SET list regardless of whether the value changed. So an idempotent UPDATE ... SET status = 'committed' on an already-committed row overwrites committedAt with a fresh clock_timestamp(), which downstream scheduling treats as a new commitment — reopening drift monitoring, resetting retry/operator state, and extending monitorUntil. Repeated idempotent writes can keep a lineage under drift monitoring indefinitely. Guard the branch with OLD.status IS DISTINCT FROM 'committed' so only the first transition into committed is stamped.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @prisma/migrations/20260729104000_harden_reconciliation_drift_cursor/migration.sql around line 23:

The `ELSIF NEW.status = 'committed'` branch restamps `committedAt` on every update whose `SET` list includes `status` or `committedAt`, even when the row was already committed. Because the trigger is `UPDATE OF status, "committedAt"`, PostgreSQL fires it whenever either column appears in the `SET` list regardless of whether the value changed. So an idempotent `UPDATE ... SET status = 'committed'` on an already-committed row overwrites `committedAt` with a fresh `clock_timestamp()`, which downstream scheduling treats as a new commitment — reopening drift monitoring, resetting retry/operator state, and extending `monitorUntil`. Repeated idempotent writes can keep a lineage under drift monitoring indefinitely. Guard the branch with `OLD.status IS DISTINCT FROM 'committed'` so only the first transition into `committed` is stamped.

-- transaction's start timestamp.
NEW."committedAt" := clock_timestamp();
END IF;
ELSIF NEW.status = 'committed' THEN

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium 20260729104000_harden_reconciliation_drift_cursor/migration.sql:23

The ELSIF NEW.status = 'committed' branch restamps committedAt on every update whose SET list includes status or committedAt, even when the row was already committed. Because the trigger is UPDATE OF status, "committedAt", PostgreSQL fires it whenever either column appears in the SET list regardless of whether the value changed. So an idempotent UPDATE ... SET status = 'committed' on an already-committed row overwrites committedAt with a fresh clock_timestamp(), which downstream scheduling treats as a new commitment — reopening drift monitoring, resetting retry/operator state, and extending monitorUntil. Repeated idempotent writes can keep a lineage under drift monitoring indefinitely. Guard the branch with OLD.status IS DISTINCT FROM 'committed' so only the first transition into committed is stamped.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @prisma/migrations/20260729104000_harden_reconciliation_drift_cursor/migration.sql around line 23:

The `ELSIF NEW.status = 'committed'` branch restamps `committedAt` on every update whose `SET` list includes `status` or `committedAt`, even when the row was already committed. Because the trigger is `UPDATE OF status, "committedAt"`, PostgreSQL fires it whenever either column appears in the `SET` list regardless of whether the value changed. So an idempotent `UPDATE ... SET status = 'committed'` on an already-committed row overwrites `committedAt` with a fresh `clock_timestamp()`, which downstream scheduling treats as a new commitment — reopening drift monitoring, resetting retry/operator state, and extending `monitorUntil`. Repeated idempotent writes can keep a lineage under drift monitoring indefinitely. Guard the branch with `OLD.status IS DISTINCT FROM 'committed'` so only the first transition into `committed` is stamped.

@macroscopeapp

macroscopeapp Bot commented Jul 29, 2026

Copy link
Copy Markdown

Approvability

Verdict: Needs human review

2 blocking correctness issues found. This PR introduces substantial new database schema for account deletion and subscription lineage tracking, including billing-related tables (custody, transfers, credits). There are unresolved review comments identifying potential bugs in the migration's backfill gap and trigger restamping behavior. Changes to billing infrastructure warrant human review.

You can customize Macroscope's approvability policy. Learn more.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant