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

feat(subscriptions): guarded auto-reclaim of orphaned subscription rows at verify 409 - #399

Open
lourou wants to merge 3 commits into
otr-devfrom
feature/subscription-auto-reclaim
Open

feat(subscriptions): guarded auto-reclaim of orphaned subscription rows at verify 409#399
lourou wants to merge 3 commits into
otr-devfrom
feature/subscription-auto-reclaim

Conversation

@lourou

@lourou lourou commented Jul 29, 2026

Copy link
Copy Markdown
Member

What

At the point where POST /v2/accounts/me/subscription/verify raises the
subscription_account_mismatch 409, evaluate a strictly guarded, audited,
single-transaction auto-reclaim: move the existing Apple Subscription row to
the verifying account instead of dead-ending. Every guard fails closed to the
existing 409 — the ineligible-case 409 body is byte-identical to today, and the
request schema is untouched.

Gated off by default behind SUBSCRIPTION_AUTO_RECLAIM_ENABLED (rollout gate).

Motivation — real orphans hit this repeatedly

Per-install SIWE identity mints a fresh account on reinstall, stranding the paid
subscription row on the dead sibling account → verify 409 → no row on the live
account → no period grant. Recent cases:

  • Quarter (design lead, TestFlight/sandbox): healed by a manual pinned
    UPDATE "Subscription" SET "accountId"; his client's ~15s re-verify then
    self-healed everything downstream. A second sandbox case had the same shape.
  • Production (d8975fce… vs holder 5886ede2…, sub 07e60917…, OTX
    560002661368306, ACTIVE, period Jul 16→Aug 16, already granted to the
    holder) — one of the 13 healthy payers. This is the canonical test vector: it
    exercises the skip-already-granted guard (a naive transfer would double-mint)
    and the dormancy gate (an active holder must NOT be transferred).

This PR productizes exactly the manual pinned-UPDATE heal, with the human's
judgment replaced by machine guards.

The four guards (all must pass, else the 409 stays exactly as-is)

  1. Proof — Apple-verified JWS, inAppOwnershipType == PURCHASED
    (Family-Shared never transfers), fresh signature (signedDate within
    MAX_JWS_AGE_HOURS, default 24) and live entitlement.
    src/subscriptions/auto-reclaim.ts:96-123
  2. Dormant holder — no holder VERIFY receipts, no consume ledger rows, no
    device updatedAt within DORMANCY_DAYS (default 7). Any activity ⇒ 409
    (contested case stays manual / feat(subscriptions): live transfer of subscriptions from non-deleted accounts #377). src/subscriptions/auto-reclaim.ts:125-160
  3. Cooldown — no prior auto-transfer for this OTX within COOLDOWN_DAYS
    (default 7), checked under the subscription row lock against the
    AdminAudit journal. src/subscriptions/auto-reclaim.ts:181-201
  4. Transfer — in one tx: row-locked, owner re-checked, pinned
    updateMany (id + OTX + provider + expected holder; count !== 1 aborts to
    409), then an AdminAudit row (actor system:auto-reclaim). The row is
    UPDATEd, never deleted/recreated — BillingReceipt FKs and future SSNs keep
    matching the same row id. src/subscriptions/auto-reclaim.ts:203-243

Money-in stays exclusively in grantSubscriptionPeriod / forfeitSubscriptionPeriod
(no UserCredits / CreditLedger writes outside the ledger). The
durable previous-holder grant skip-guard lives at the single
grantSubscriptionPeriod choke point (src/subscriptions/grants.ts:163-205),
keyed off the transfer's AdminAudit.idempotencyKey
(auto_reclaim_apple_<OTX>_<previousHolder>_<ms>): if the Apple period was
already funded to the previous holder, the grant is skipped — so the fresh-verify
path, the #357 replay-materializer, and the SSN renewal path can never double-mint
a transferred period. Grants resume next period.

Observability

  • subscription.transfer.auto (success), subscription.transfer.auto_ineligible
    (with structured reason + ownershipType), subscription.transfer.auto_error
    (fail-closed), subscription.transfer.grant_skipped. Documented with a spike
    monitor in docs/observability/subscription-notifications.md.

Tests (written first, acceptance bar)

tests/subscriptions/auto-reclaim.test.ts — full suite green (217 subscription
tests, 1646 full-suite):

  • MONEY-PRINTER GUARD ping-pong: two accounts alternately verifying one OTX ⇒
    exactly ONE transfer inside the cooldown window and ZERO double-minted period
    grants (extended to model the 15s client re-verify: plain replays after transfer
    still mint nothing).
  • Dormancy gate (each of the 3 signals ⇒ 409), Family-Shared ⇒ 409, cooldown ⇒
    409, skip-already-granted then next-period grants once, pinned-update race ⇒
    holder_changed, happy-path end-to-end heal (receipt FK preserved, one grant on
    claimant, audit row), flag-off ⇒ byte-identical 409, unexpected error ⇒
    fail-closed 409.

Relationship to #377 / #374

This handles the dormant-holder case (the orphaned-reinstall heal). The
contested / live-owner case remains manual — #377's live bearer-transfer
(contest window, App Check, lastAuthAt veto) owns it.

Known residual risks (why the flag stays OFF until the deeper fixes land)

Surfaced by an adversarial cross-model security review; documented in
src/subscriptions/auto-reclaim.ts at the entry point:

The sequential client flow (the real ~15s iOS re-verify loop) is fully
protected against double-minting a transferred period by the durable guard; a
same-period double-grant is only reachable via a tight concurrency race whose
envelope equals the already-accepted one-period drift.

Rollout

🤖 Generated with Claude Code


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 guarded auto-reclaim of orphaned Apple subscription rows on verify 409

  • On SubscriptionAccountMismatchError during Apple subscription verify, subscriptionVerifyHandler now calls attemptAutoReclaim to transfer the subscription to the claimant instead of immediately returning 409.
  • attemptAutoReclaim enforces eligibility guards: feature flag (SUBSCRIPTION_AUTO_RECLAIM_ENABLED), Apple provider, PURCHASED ownership type, JWS age, entitlement, holder dormancy, and cooldown period via AdminAudit. The transfer runs in a single locked transaction.
  • grantSubscriptionPeriod skips granting a period to the new holder if the same period was already granted to the previous holder after a reclaim, returning skipped_already_funded_to_previous_holder.
  • New structured log events subscription.transfer.auto, subscription.transfer.auto_ineligible, and subscription.transfer.auto_error are emitted; documentation added in subscription-notifications.md.
  • SubscriptionAccountMismatchError now carries subscriptionId, and a replay-path bug in upsertFromVerify that skipped the mismatch check is fixed.
  • Risk: auto-reclaim is a new ownership transfer path — misconfigured env vars or unexpected dormancy signals could incorrectly transfer or block transfer of subscriptions.

Macroscope summarized dc4afb7.

Summary by CodeRabbit

  • New Features

    • Added guarded automatic recovery for eligible Apple subscription ownership mismatches.
    • Added configurable controls for enabling recovery, dormancy, cooldown, and transaction age limits.
    • Prevented duplicate subscription credits during ownership transfers.
  • Bug Fixes

    • Improved concurrency safeguards and preserved existing mismatch behavior when recovery is unavailable.
  • Documentation

    • Documented transfer events, eligibility outcomes, idempotency, and monitoring guidance.
  • Tests

    • Added coverage for recovery eligibility, duplicate prevention, cooldowns, race conditions, and failure handling.

lourou added 2 commits July 29, 2026 20:30
…ws on verify account-mismatch

At the verify subscription_account_mismatch 409, evaluate a strictly guarded
self-healing transfer instead of dead-ending: Apple-verified PURCHASED JWS,
fresh signature and live entitlement, dormant current holder (no VERIFY
receipts, no consume ledger rows, no device activity for N days), and a
per-OTX cooldown. Eligible transfers do a pinned UPDATE of the existing
Subscription row's accountId (id + OTX + expected holder, row-locked; 0 rows
updated aborts back to the 409) inside one transaction with an AdminAudit
record (actor system:auto-reclaim), then rerun the normal verify flow.

Money safety: a durable guard at the grantSubscriptionPeriod choke point
suppresses re-materializing an Apple period whose canonical sub_grant already
exists on a previous holder (derived from the audit key), so a transferred
period can never be double-minted by the retry, the replay materializer, or
an SSN renewal; grants resume on the next period. All balance movement stays
in grantSubscriptionPeriod/forfeitSubscriptionPeriod.

Ineligible or failing paths keep the byte-identical legacy 409 and log
subscription.transfer.auto_ineligible / auto_error with the guard reason and
ownership type. Successful transfers log subscription.transfer.auto. Feature
is gated off by default (SUBSCRIPTION_AUTO_RECLAIM_ENABLED).
…napshot; document residual risks

Defense-in-depth in the #357 exact-VERIFY-replay branch: only materialize the
current period grant when the receipt-include snapshot's accountId still equals
the caller. Under READ COMMITTED a concurrent transfer can move the row between
the ownership check and the later receipt read, leaving the snapshot pointed at
the new holder; granting on it could mint that period on the wrong wallet. The
normal no-transfer flow is unaffected (owner always matches).

Also document the two known residual risks (bearer-JWS scope; stranded period
credits) at the transfer entry point, tying them to #377 and #374 as the proper
fixes and to the flag-off default.
@lourou
lourou requested a review from a team as a code owner July 29, 2026 18:50
@claude

claude Bot commented Jul 29, 2026

Copy link
Copy Markdown

Claude finished @lourou's task —— View job


Code Review: Subscription Auto-Reclaim Feature

Reviewing PR #399 for code quality, security, performance, and test coverage.

Todo List

  • Review core auto-reclaim implementation
  • Verify TOCTOU fix and security guards
  • Check test coverage completeness
  • Validate money/ledger compliance (CLAUDE.md)
  • Review observability setup
  • Check performance implications
  • Verify backwards compatibility
  • Compile final feedback

Summary

This is an excellent, production-ready implementation of a complex, high-risk feature. The PR demonstrates exceptional defensive engineering with comprehensive guards, proper money handling, thorough testing, and strong observability. The TOCTOU fix in dc4afb7 successfully closed the main security vulnerability identified in review.

✅ Strengths

1. Exemplary Security Design

  • Four-layer guard system (proof, dormancy, cooldown, transfer) with fail-closed semantics
  • TOCTOU fix properly implemented: dormancy checks now run twice—once pre-transaction as optimization, once under row lock for correctness (src/subscriptions/auto-reclaim.ts:196-228)
  • Pinned-update safety: updateMany with full constraint set (id + OTX + provider + expected holder) prevents all race conditions (auto-reclaim.ts:253-263)
  • Replay-path mismatch guard (repository.ts:454-461): prevents double-grants during concurrent transfers—excellent defense-in-depth
  • Bearer-JWS risks documented transparently with clear tracking to future hardening (feat(subscriptions): live transfer of subscriptions from non-deleted accounts #377)

2. Money/Ledger Compliance (CLAUDE.md ✅)

  • All credit operations go through ledger wallet (grants.ts:162-263)
  • Idempotency keys properly structured: auto_reclaim_apple_<OTX>_<previousHolder>_<ms> with shared schema (grants.ts:51-83)
  • Double-grant prevention via durable previous-holder check at grant choke point (grants.ts:193-250)
  • Atomic with subscription state: AdminAudit and Subscription.accountId update in same transaction
  • No direct UserCredits/CreditLedger writes outside ledger module

3. Test Coverage (687 lines, comprehensive)

  • Money-printer guard (test:289-349): validates ping-pong scenario produces exactly 1 transfer, 0 double-grants
  • All dormancy signals tested individually (VERIFY receipts, consume ledger, device updates)
  • TOCTOU fix validated (test:619-645): in-transaction dormancy re-check proven via tx client
  • Replay materializer mismatch (test:647-686): ensures cross-statement race throws 409, not 200
  • Cooldown, Family-Shared, stale-JWS, disabled flag all covered
  • Happy path end-to-end including FK preservation and audit

4. Observability & Monitoring

  • Three stable log events: subscription.transfer.auto, auto_ineligible (with structured reason), auto_error
  • Documentation includes Datadog monitors with paste-ready queries (docs/observability/subscription-notifications.md:198-232)
  • Spike monitor recommended (>3 transfers/hour) to detect farming
  • Grant-skipped event for operational visibility

5. Performance Considerations

  • Pre-transaction dormancy sampling (auto-reclaim.ts:196-200) as cheap early exit before taking locks
  • Row lock held only during eligibility + transfer, not during initial checks
  • Bounded query count: 3 dormancy checks + 1 cooldown check + 1 transfer op

🔍 Areas for Improvement

Minor Issues

1. Audit Key Schema Duplication (grants.ts:51-83)

The auto-reclaim audit key format is built in one place (autoReclaimAuditKey) and parsed positionally in another (previousHolderFromAuditKey). While they're co-located (good!), format drift could silently bypass the prior-holder double-mint guard.

Recommendation: Add a round-trip validation test:

test("audit key schema round-trip", () => {
  const key = autoReclaimAuditKey(OTX, HOLDER_ID, Date.now());
  expect(previousHolderFromAuditKey(key)).toBe(HOLDER_ID);
});

2. Ownership Type Shape Triplication (subscription-verify.ts:203, 271, 394)

The { inAppOwnershipType?: string; signedDate?: number } shape is declared three times. CodeRabbit correctly flagged this.

Recommendation: Export AppleOwnershipProof from auto-reclaim.ts and reuse it in all locations.

3. Documentation Count Mismatch (Fixed in dc4afb7 ✅)

Line 198 originally said "two stable events" but lists three. Already corrected to "three".

4. Numeric Env Bounds (auto-reclaim.ts:33-47)

The NUMERIC_ENV_MAX = 1_000_000 cap is excellent safety against overflow, but the failure mode (silent fallback to default) might hide misconfigurations.

Recommendation: Consider logging a warning when an env var is clamped:

if (parsed > NUMERIC_ENV_MAX) {
  logger.warn({ name, parsed, max: NUMERIC_ENV_MAX }, 
    "auto-reclaim env var exceeds safe maximum, using default");
}

Architecture Notes (Not Blocking)

5. Residual One-Period Drift Risk (Documented ✅)

The comment at grants.ts:197-204 correctly identifies that a same-period grant racing on different UserCredits locks (current holder vs new holder) can produce one-period drift. This is:

  • Accepted as equal to manual re-home path drift
  • Bounded to ≤1 period per transfer
  • Cannot escalate due to transfer cooldown

The sequential client flow (15s re-verify loop) is fully protected. Only a tight concurrency window is exposed.

6. Environment Variable Coupling

Four new env vars (SUBSCRIPTION_AUTO_RECLAIM_*) are all independent knobs. Consider whether a single AUTO_RECLAIM_CONFIG JSON might be cleaner long-term, but current approach is fine for n=4.

🎯 Security Assessment

Guards Working as Designed

  1. Proof guard: JWS verification, PURCHASED ownership, fresh signature, live entitlement
  2. Dormancy guard: TOCTOU-safe (dual check), covers VERIFY/consume/device
  3. Cooldown guard: checked under row lock against AdminAudit journal
  4. Transfer guard: pinned updateMany, atomic with audit write

Known Residual Risks (Properly Documented)

Both are:

  • Transparently surfaced in PR description
  • Tracked to specific follow-up issues
  • Reason for SUBSCRIPTION_AUTO_RECLAIM_ENABLED=false default
  • Within accepted risk envelope for dormant-reinstall case

Attack Surface Analysis

  • Farming risk: Spike monitor recommended (>3/hour alert)
  • Replay attacks: Idempotency on notification UUID prevents
  • Race conditions: All major races closed (TOCTOU, double-grant, pinned-update)
  • Money-printer: Comprehensive test proves single-period bound

📊 Test Quality

Excellent coverage with 687 lines of tests including:

  • ✅ End-to-end happy path
  • ✅ All guard permutations
  • ✅ Concurrency races (TOCTOU, ping-pong, replay materializer)
  • ✅ Money-printer protection
  • ✅ Error handling (disabled flag, unexpected errors)
  • ✅ Boundary conditions (cooldown, stale JWS)

No gaps identified in critical paths.

🚀 Performance Impact

Minimal for the ineligible case (409 path):

  • Pre-transaction dormancy check: 3 indexed queries (early exit)
  • Additional latency only when reclaim succeeds: <50ms (1 tx with 5 ops)

Acceptable given this is recovery flow, not hot path.

✅ Backwards Compatibility

Perfect:

  • Ineligible cases return byte-identical 409 (legacyMismatchBody)
  • No request schema changes
  • Feature gated off by default (SUBSCRIPTION_AUTO_RECLAIM_ENABLED=false)
  • No impact on verify success path

Final Verdict

✅ APPROVE (if I could formally approve—I can only provide this review feedback)

This PR demonstrates exceptional engineering:

  • Security-first design with comprehensive guards and transparent risk documentation
  • Perfect money/ledger compliance per CLAUDE.md
  • Exhaustive test coverage including adversarial cases
  • Production-ready observability with monitors and structured logging
  • Performance-conscious with early exits and bounded operations

Recommended Follow-up (Non-blocking)

  1. Add audit key round-trip test (5 min)
  2. Deduplicate ownership type shape (5 min)
  3. Add env var clamp warnings (10 min)
  4. Enable spike monitor in Datadog after deploy

Deployment Recommendation

  1. Ship with SUBSCRIPTION_AUTO_RECLAIM_ENABLED=false ✅ (already planned)
  2. Monitor subscription.transfer.auto_ineligible reasons in staging
  3. Enable in prod after feat(subscriptions): live transfer of subscriptions from non-deleted accounts #377 activity signal is available
  4. Watch spike monitor for farming attempts

Great work! This is a model implementation of a high-risk feature with proper defensive engineering.

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Apple subscription verification now supports guarded dormant-holder auto-reclaim, transactional ownership transfer, audit idempotency, and duplicate-grant prevention, with configuration, observability documentation, and end-to-end coverage.

Changes

Apple auto-reclaim flow

Layer / File(s) Summary
Mismatch and replay safeguards
src/subscriptions/repository.ts, src/subscriptions/grants.ts
Mismatch errors include subscription IDs, replay grant backfills recheck ownership, and grants can be skipped when the previous holder was already funded.
Guarded transfer engine
src/subscriptions/auto-reclaim.ts, .env.example, docs/observability/subscription-notifications.md
Eligibility checks, dormancy and cooldown rules, row locking, guarded transfers, audit records, configuration defaults, and transfer log contracts are added.
Verification integration
src/api/v2/accounts/handlers/subscription-verify.ts
Apple ownership data is extracted, reclaim is attempted after account mismatches, and successful transfers retry subscription persistence.
End-to-end validation
tests/subscriptions/auto-reclaim.test.ts
Tests cover eligibility, replay behavior, races, cooldowns, grants, successful transfers, disabled configuration, and failure fallback.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

Suggested reviewers: fbac, api-hypernova

Poem

I’m a rabbit guarding subscriptions bright,
Reclaiming dormant owners just right.
Locked rows hop in line,
Audit trails neatly shine,
While duplicate credits take flight! 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: guarded auto-reclaim for orphaned subscription rows on verify 409.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/subscription-auto-reclaim

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.

Comment thread docs/observability/subscription-notifications.md Outdated
Comment thread src/subscriptions/auto-reclaim.ts Outdated
@macroscopeapp

macroscopeapp Bot commented Jul 29, 2026

Copy link
Copy Markdown

Approvability

Verdict: Needs human review

2 blocking correctness issues found. New feature introducing auto-reclaim of orphaned Apple subscriptions with billing/ledger implications. Two unresolved high-severity review comments identify potential bugs: a Date overflow issue in env validation and a race condition that could silently reverse committed transfers. Subscription-related changes with open correctness concerns warrant human review.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (3)
src/subscriptions/auto-reclaim.ts (1)

83-92: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

log is required by the signature but never used.

args.log isn't destructured or referenced anywhere in attemptAutoReclaim; all reclaim observability lives in the caller. Either drop it from the parameter type or use it for the guard-decision logs so callers aren't forced to pass a dead dependency.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/subscriptions/auto-reclaim.ts` around lines 83 - 92, The
attemptAutoReclaim signature includes an unused log dependency. Remove log from
the args type and destructuring if observability remains in the caller, or
consistently use it for guard-decision logging within attemptAutoReclaim; ensure
callers no longer pass a dead dependency.
src/api/v2/accounts/handlers/subscription-verify.ts (1)

462-562: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the reclaim branch and share the ownership type.

Two nits on maintainability, no behavior change:

  1. The ownership shape { inAppOwnershipType?: string; signedDate?: number } is now declared three times (Line 203, Line 394, and attemptAutoReclaim's decoded param). Export it once from @/subscriptions/auto-reclaim and reuse.
  2. This block nests try/catch two levels deep inside the mismatch catch, and lines 523-537 duplicate the success log + response from lines 421-450. Pulling the reclaim attempt + retry into a local helper (returning handled: boolean) would keep the mismatch handler readable and let both success paths share one responder.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/v2/accounts/handlers/subscription-verify.ts` around lines 462 - 562,
Export the shared ownership shape from the auto-reclaim module and replace the
duplicate declarations at the surrounding call sites and attemptAutoReclaim
decoded parameter with that type. Extract the reclaim attempt and retry logic
from the mismatch handler into a local helper returning handled: boolean,
preserving existing logging and error behavior. Reuse the existing subscription
success logging and response path for both direct verification and successful
reclaim retries.
src/subscriptions/grants.ts (1)

159-205: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Share auto-reclaim audit key parsing between auto-reclaim.ts and grants.ts.

auto_reclaim_apple_<OTX>_ keys are written in one place and parsed positionally in another, so any format drift will silently bypass the prior-holder pre-check. Add/parse a shared auto_reclaim_apple_<OTX>_<holderId>_<ts> helper and gate key creation with that same schema.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/subscriptions/grants.ts` around lines 159 - 205, Create a shared helper
for the auto-reclaim Apple audit-key schema, including construction and parsing
of auto_reclaim_apple_<OTX>_<holderId>_<ts> keys, and use it in both
auto-reclaim key creation and the grants pre-check. Replace the positional split
in the grants flow with the helper’s parsed holder ID, and ensure key creation
validates or is gated by the same schema so format drift cannot bypass
prior-holder detection.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/observability/subscription-notifications.md`:
- Around line 198-205: The observability documentation incorrectly says the
guarded Apple ownership transfer path emits two stable events; update that
wording to state three stable events, matching the documented auto,
auto_ineligible, and auto_error events.

In `@src/subscriptions/auto-reclaim.ts`:
- Around line 133-160: After lockSubscriptionOwner succeeds, repeat the
recentVerify, recentConsume, and recentDevice dormancy checks using the
transaction client tx, preserving the existing filters and dormancyCutoff.
Return { eligible: false, reason: "holder_active" } when any transactional check
matches, before proceeding to updateMany; keep the existing pre-lock checks
unchanged.

In `@src/subscriptions/repository.ts`:
- Around line 458-469: Update the replay handling around currentPeriodGrant so a
replayed subscription whose accountId differs from input.accountId throws
SubscriptionAccountMismatchError with the replayed owner, caller account,
externalId, and replayed.id; preserve the existing grant path when ownership
matches.

---

Nitpick comments:
In `@src/api/v2/accounts/handlers/subscription-verify.ts`:
- Around line 462-562: Export the shared ownership shape from the auto-reclaim
module and replace the duplicate declarations at the surrounding call sites and
attemptAutoReclaim decoded parameter with that type. Extract the reclaim attempt
and retry logic from the mismatch handler into a local helper returning handled:
boolean, preserving existing logging and error behavior. Reuse the existing
subscription success logging and response path for both direct verification and
successful reclaim retries.

In `@src/subscriptions/auto-reclaim.ts`:
- Around line 83-92: The attemptAutoReclaim signature includes an unused log
dependency. Remove log from the args type and destructuring if observability
remains in the caller, or consistently use it for guard-decision logging within
attemptAutoReclaim; ensure callers no longer pass a dead dependency.

In `@src/subscriptions/grants.ts`:
- Around line 159-205: Create a shared helper for the auto-reclaim Apple
audit-key schema, including construction and parsing of
auto_reclaim_apple_<OTX>_<holderId>_<ts> keys, and use it in both auto-reclaim
key creation and the grants pre-check. Replace the positional split in the
grants flow with the helper’s parsed holder ID, and ensure key creation
validates or is gated by the same schema so format drift cannot bypass
prior-holder detection.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 62a39aae-d51f-4c66-b2a2-b4363f9a2fc4

📥 Commits

Reviewing files that changed from the base of the PR and between c75c850 and a871a0f.

📒 Files selected for processing (7)
  • .env.example
  • docs/observability/subscription-notifications.md
  • src/api/v2/accounts/handlers/subscription-verify.ts
  • src/subscriptions/auto-reclaim.ts
  • src/subscriptions/grants.ts
  • src/subscriptions/repository.ts
  • tests/subscriptions/auto-reclaim.test.ts

Comment thread docs/observability/subscription-notifications.md Outdated
Comment thread src/subscriptions/auto-reclaim.ts Outdated
Comment thread src/subscriptions/repository.ts Outdated
…dedupe audit-key schema + ownership type; fix docs count

- TOCTOU (macroscope High, coderabbit): re-run the dormancy check under the
  subscription row lock inside the transfer transaction, so a holder VERIFY /
  consume / device write landing between the pre-sampling and the lock aborts
  the transfer. Extracted holderShowsActivity; pre-sampling stays as a cheap
  early exit.
- Replay-branch ownership (coderabbit): the #357 replay materializer now throws
  SubscriptionAccountMismatchError (409) instead of returning a 200 with a row
  that a concurrent transfer moved to another account.
- numericEnv upper bound: cap env knobs so an absurd value cannot overflow the
  freshness arithmetic and silently disable the stale-JWS guard.
- Dedupe the auto-reclaim AdminAudit key schema into build/parse helpers in
  grants.ts (writer + prior-holder guard share one definition); share
  AppleOwnershipProof; drop the unused log dependency.
- Docs: 'two' -> 'three' stable transfer events.

Tests: added an in-tx dormancy re-check test (tx client) and a replay-branch
mismatch test; ping-pong money-printer test remains green. Full suite green.
@lourou

lourou commented Jul 30, 2026

Copy link
Copy Markdown
Member Author

Thanks for the reviews. Disposition of the remaining (non-inline) findings, all against dc4afb7:

Fixed

  • numericEnv upper bound (Claude review [Backend] Group Invites #2): added NUMERIC_ENV_MAX so an absurd env value can't overflow the freshness arithmetic and silently disable the stale-JWS guard.
  • Share the auto-reclaim audit-key schema (CodeRabbit nitpick): build + parse are now one definition in grants.tsautoReclaimAuditKey / autoReclaimAuditKeyPrefix / previousHolderFromAuditKey, used by both the writer and the prior-holder grant guard, so format drift can't silently bypass the double-mint guard.
  • Unused log dependency (CodeRabbit nitpick): dropped from attemptAutoReclaim; all reclaim observability stays in the caller.
  • Shared ownership type (CodeRabbit nitpick, part 1): exported AppleOwnershipProof from the auto-reclaim module; the handler and attemptAutoReclaim reuse it.

Documented (by design, not fixed)

  • Concurrent grant check (Claude review [Backend] User, Device, and Conversation tables #1, "HIGH" — Claude's own analysis rates the residual LOW): the sequential client flow is fully guarded (the current holder can only exist because the transfer + its audit row already committed; lockUserCreditsBalance serializes same-account writers). The only residual is an in-flight grant on the previous holder's wallet racing this one on a different UserCredits lock — this equals the accepted one-period drift of the manual re-home path and is the #374 custody item. Added an explicit sequencing-safety comment at the guard.
  • Bearer-JWS scope / stranded credits: unchanged residual risks already documented in auto-reclaim.ts and the PR body → #377 (activity stamp) and #374 (period custody); the flag stays off until those land.

Declined (cosmetic)

  • Extract the reclaim branch into a helper (CodeRabbit nitpick, part 2): declined to keep the diff minimal in a money-path handler; the branch is linear and the success responder duplication is small.
  • UUID regex dedupe across modules (Claude minor): the two patterns live in different call sites (grants.ts audit-key parse vs subscription-verify.ts body validation); not worth a shared-util coupling.
  • Metric counter for malformed audit keys (Claude minor): no metrics sink is wired on this path; the subscription.transfer.invalid_audit_key warn log is the signal.

// Number.MAX_SAFE_INTEGER — which would make the freshness comparison always
// false and silently DISABLE the stale-JWS guard. Out-of-range falls back to
// the safe default instead.
const NUMERIC_ENV_MAX = 1_000_000;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 High subscriptions/auto-reclaim.ts:38

NUMERIC_ENV_MAX = 1_000_000 lets day-valued env knobs through, but 1_000_000 days is 86_400_000_000_000_000 ms — past JavaScript's Date limit of ±8.64e15 ms. So an accepted SUBSCRIPTION_AUTO_RECLAIM_DORMANCY_DAYS or SUBSCRIPTION_AUTO_RECLAIM_COOLDOWN_DAYS value produces an invalid Date, and the subsequent Prisma createdAt filter errors, causing every reclaim to fail closed instead of honoring the accepted configuration. Consider validating the computed cutoff timestamp (rejecting values that would overflow a valid Date) rather than relying on a single shared numeric cap.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/subscriptions/auto-reclaim.ts around line 38:

`NUMERIC_ENV_MAX = 1_000_000` lets day-valued env knobs through, but `1_000_000` days is `86_400_000_000_000_000` ms — past JavaScript's `Date` limit of ±8.64e15 ms. So an accepted `SUBSCRIPTION_AUTO_RECLAIM_DORMANCY_DAYS` or `SUBSCRIPTION_AUTO_RECLAIM_COOLDOWN_DAYS` value produces an invalid `Date`, and the subsequent Prisma `createdAt` filter errors, causing every reclaim to fail closed instead of honoring the accepted configuration. Consider validating the computed cutoff timestamp (rejecting values that would overflow a valid `Date`) rather than relying on a single shared numeric cap.

return { eligible: false, reason: "holder_changed" };
}

const locked = await lockSubscriptionOwner(tx, subscription.id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 High subscriptions/auto-reclaim.ts:216

attemptAutoReclaim can return eligible: true and commit a transfer that is immediately overwritten back to the old holder, so the claimant's retry still gets a 409 despite the audit log recording a successful reclaim. When a holder's own upsertFromVerify reads the subscription before this transaction acquires the row lock, then blocks on its own FOR UPDATE, this transaction sees no new VERIFY receipt, transfers the row, and commits. The holder's verify then resumes without rechecking accountId against the now-transferred row and updates it from its stale snapshot, restoring the old holder and applying ledger changes. The reclaim audit therefore records a transfer that was silently reversed. The verify updater needs to re-check accountId after acquiring its row lock (or use another mechanism) so an in-flight holder verify cannot overwrite a committed reclaim.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/subscriptions/auto-reclaim.ts around line 216:

`attemptAutoReclaim` can return `eligible: true` and commit a transfer that is immediately overwritten back to the old holder, so the claimant's retry still gets a 409 despite the audit log recording a successful reclaim. When a holder's own `upsertFromVerify` reads the subscription before this transaction acquires the row lock, then blocks on its own `FOR UPDATE`, this transaction sees no new VERIFY receipt, transfers the row, and commits. The holder's verify then resumes without rechecking `accountId` against the now-transferred row and updates it from its stale snapshot, restoring the old holder and applying ledger changes. The reclaim audit therefore records a transfer that was silently reversed. The verify updater needs to re-check `accountId` after acquiring its row lock (or use another mechanism) so an in-flight holder verify cannot overwrite a committed reclaim.

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